feat: добавлено автозаполнение полей wishlist из ссылки (v3.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s

- Добавлен эндпоинт /api/wishlist/metadata для извлечения метаданных из URL
- Реализовано извлечение Open Graph тегов (title, image, description)
- Добавлена кнопка Pull для ручной загрузки информации из ссылки
- Автоматическое заполнение полей: название, цена, картинка
- Обновлена версия до 3.9.0
This commit is contained in:
poignatov
2026-01-11 21:12:26 +03:00
parent 932dba8682
commit e2059ef555
22 changed files with 3937 additions and 21 deletions

View File

@@ -48,6 +48,18 @@ server {
expires 0;
}
# Раздача загруженных файлов (картинки wishlist) - проксируем через backend
location ^~ /uploads/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;

View File

@@ -1,12 +1,12 @@
{
"name": "play-life-web",
"version": "3.7.0",
"version": "3.8.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "play-life-web",
"version": "3.7.0",
"version": "3.8.9",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -14,7 +14,8 @@
"chart.js": "^4.4.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -5482,6 +5483,12 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5920,6 +5927,20 @@
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.8.9",
"version": "3.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,8 @@
"chart.js": "^4.4.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6"
},
"devDependencies": {
"@types/react": "^18.2.43",

View File

@@ -10,6 +10,9 @@ import TestWords from './components/TestWords'
import Profile from './components/Profile'
import TaskList from './components/TaskList'
import TaskForm from './components/TaskForm.jsx'
import Wishlist from './components/Wishlist'
import WishlistForm from './components/WishlistForm'
import WishlistDetail from './components/WishlistDetail'
import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration'
import { AuthProvider, useAuth } from './components/auth/AuthContext'
@@ -21,8 +24,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'test-config', 'tasks', 'profile']
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -53,6 +56,9 @@ function AppContent() {
test: false,
tasks: false,
'task-form': false,
wishlist: false,
'wishlist-form': false,
'wishlist-detail': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
@@ -70,6 +76,9 @@ function AppContent() {
test: false,
tasks: false,
'task-form': false,
wishlist: false,
'wishlist-form': false,
'wishlist-detail': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
@@ -106,6 +115,7 @@ function AppContent() {
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
@@ -118,7 +128,7 @@ function AppContent() {
// Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его
@@ -492,7 +502,7 @@ function AppContent() {
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
useEffect(() => {
const handlePopState = (event) => {
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
// Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) {
@@ -597,8 +607,8 @@ function AppContent() {
setSelectedProject(null)
setTabParams({})
updateUrl('full', {}, activeTab)
} else if (tab !== activeTab || tab === 'task-form') {
// Для task-form всегда обновляем параметры, даже если это тот же таб
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form') {
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб
markTabAsLoaded(tab)
// Определяем, является ли текущий таб глубоким
@@ -616,8 +626,9 @@ function AppContent() {
updateUrl(tab, {}, activeTab)
}
} else {
// Для task-form явно удаляем taskId, если он undefined
if (tab === 'task-form' && params.taskId === undefined) {
// Для task-form и wishlist-form явно удаляем параметры, если они undefined
if ((tab === 'task-form' && params.taskId === undefined) ||
(tab === 'wishlist-form' && params.wishlistId === undefined)) {
setTabParams({})
if (isNewTabMain) {
clearUrl()
@@ -653,6 +664,10 @@ function AppContent() {
if (activeTab === 'task-form' && tab === 'tasks') {
fetchTasksData(true)
}
// Обновляем список желаний при возврате из экрана редактирования
if (activeTab === 'wishlist-form' && tab === 'wishlist') {
setWishlistRefreshTrigger(prev => prev + 1)
}
// Загрузка данных произойдет в useEffect при изменении activeTab
}
}
@@ -705,7 +720,7 @@ function AppContent() {
}, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
// Определяем отступы для контейнера
const getContainerPadding = () => {
@@ -854,6 +869,36 @@ function AppContent() {
</div>
)}
{loadedTabs.wishlist && (
<div className={activeTab === 'wishlist' ? 'block' : 'hidden'}>
<Wishlist
onNavigate={handleNavigate}
refreshTrigger={wishlistRefreshTrigger}
/>
</div>
)}
{loadedTabs['wishlist-form'] && (
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
<WishlistForm
key={tabParams.wishlistId || 'new'}
onNavigate={handleNavigate}
wishlistId={tabParams.wishlistId}
/>
</div>
)}
{loadedTabs['wishlist-detail'] && (
<div className={activeTab === 'wishlist-detail' ? 'block' : 'hidden'}>
<WishlistDetail
key={tabParams.wishlistId}
onNavigate={handleNavigate}
wishlistId={tabParams.wishlistId}
onRefresh={() => setWishlistRefreshTrigger(prev => prev + 1)}
/>
</div>
)}
{loadedTabs.profile && (
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
<Profile onNavigate={handleNavigate} />
@@ -938,6 +983,28 @@ function AppContent() {
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('wishlist')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'wishlist' || activeTab === 'wishlist-form'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Желания"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
</span>
{(activeTab === 'wishlist' || activeTab === 'wishlist-form') && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('profile')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${

View File

@@ -88,7 +88,7 @@
position: absolute;
top: 0.5rem;
right: 0;
background: transparent;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 6px;
width: 40px;
@@ -107,7 +107,8 @@
}
.card-menu-button:hover {
opacity: 0.8;
background: rgba(255, 255, 255, 0.3);
opacity: 1;
transform: scale(1.1);
}

View File

@@ -0,0 +1,309 @@
.wishlist {
max-width: 42rem; /* max-w-2xl = 672px */
margin: 0 auto;
padding-bottom: 5rem;
}
.add-wishlist-button {
width: 100%;
padding: 0.75rem 1rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-bottom: 1.5rem;
transition: all 0.2s;
}
.add-wishlist-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.section-divider {
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e5e7eb;
}
.section-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
}
.completed-toggle {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
width: 100%;
}
.completed-toggle:hover {
color: #3498db;
}
.completed-toggle-icon {
font-size: 0.75rem;
}
.loading-completed {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.wishlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.wishlist-card {
border-radius: 18px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
position: relative;
display: flex;
flex-direction: column;
}
.wishlist-card .card-image {
border-radius: 18px;
}
.wishlist-card:hover {
transform: translateY(-2px);
}
.wishlist-card.faded {
opacity: 0.45;
}
.card-menu-button {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: rgba(255, 255, 255, 0.7) !important;
border: none !important;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 1.1rem;
color: #000000;
z-index: 10;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.card-menu-button:hover {
background: rgba(255, 255, 255, 0.9) !important;
color: #333333;
transform: scale(1.1);
}
.card-image {
aspect-ratio: 5 / 6;
background: #f0f0f0;
overflow: hidden;
position: relative;
border-radius: 18px;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 18px;
}
.card-image .placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
background: white;
border-radius: 18px;
}
.card-name {
padding: 0.6rem 0 0;
font-weight: 600;
font-size: 1.25rem;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.card-price {
padding: 0;
color: #aaa;
font-size: 0.9rem;
}
.unlock-condition-wrapper {
padding: 0 0 0.5rem;
}
.unlock-condition-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0;
}
.unlock-condition {
display: flex;
align-items: center;
gap: 0.25rem;
color: #888;
font-size: 0.85rem;
flex: 1;
min-width: 0;
}
.unlock-condition .lock-icon {
flex-shrink: 0;
width: 12px;
height: 12px;
}
.condition-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.more-conditions {
padding-left: calc(12px + 0.25rem);
margin-top: -0.15rem;
color: #888;
font-size: 0.85rem;
}
.wishlist-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.wishlist-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.wishlist-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.wishlist-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.wishlist-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.wishlist-modal-edit,
.wishlist-modal-complete,
.wishlist-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.wishlist-modal-edit {
background-color: #3498db;
color: white;
}
.wishlist-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.wishlist-modal-complete {
background-color: #27ae60;
color: white;
}
.wishlist-modal-complete:hover {
background-color: #229954;
transform: translateY(-1px);
}
.wishlist-modal-delete {
background-color: #e74c3c;
color: white;
}
.wishlist-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}

View File

@@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Wishlist.css'
const API_URL = '/api/wishlist'
function Wishlist({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
useEffect(() => {
fetchWishlist()
}, [])
// Обновляем данные при изменении refreshTrigger
useEffect(() => {
if (refreshTrigger > 0) {
fetchWishlist()
// Если завершённые развёрнуты, обновляем и их
if (completedExpanded) {
fetchWishlist(true)
}
}
}, [refreshTrigger])
const fetchWishlist = async (includeCompleted = false) => {
try {
if (includeCompleted) {
setCompletedLoading(true)
} else {
setLoading(true)
}
const url = includeCompleted ? `${API_URL}?include_completed=true` : API_URL
const response = await authFetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке желаний')
}
const data = await response.json()
// Объединяем разблокированные и заблокированные в один список
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
setItems(allItems)
if (includeCompleted) {
setCompleted(data.completed || [])
}
setError('')
} catch (err) {
setError(err.message)
setItems([])
if (includeCompleted) {
setCompleted([])
}
} finally {
setLoading(false)
setCompletedLoading(false)
}
}
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
if (newExpanded && completed.length === 0) {
fetchWishlist(true)
}
}
const handleAddClick = () => {
onNavigate?.('wishlist-form', { wishlistId: undefined })
}
const handleItemClick = (item) => {
onNavigate?.('wishlist-detail', { wishlistId: item.id })
}
const handleMenuClick = (item, e) => {
e.stopPropagation()
setSelectedItem(item)
}
const handleEdit = () => {
if (selectedItem) {
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id })
setSelectedItem(null)
}
}
const handleDelete = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
setSelectedItem(null)
await fetchWishlist(completedExpanded)
} catch (err) {
setError(err.message)
setSelectedItem(null)
}
}
const handleComplete = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}/complete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при завершении')
}
setSelectedItem(null)
await fetchWishlist(completedExpanded)
} catch (err) {
setError(err.message)
setSelectedItem(null)
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const renderUnlockCondition = (item) => {
if (item.unlocked || item.completed) return null
if (!item.first_locked_condition) return null
const condition = item.first_locked_condition
const moreCount = item.more_locked_conditions || 0
let conditionText = ''
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
} else {
const points = condition.required_points || 0
const project = condition.project_name || 'Проект'
let period = ''
if (condition.period_type) {
const periodLabels = {
week: 'за неделю',
month: 'за месяц',
year: 'за год',
}
period = ' ' + periodLabels[condition.period_type] || ''
}
conditionText = `${points} в ${project}${period}`
}
return (
<div className="unlock-condition-wrapper">
<div className="unlock-condition-line">
<div className="unlock-condition">
<svg className="lock-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
</svg>
<span className="condition-text">{conditionText}</span>
</div>
{item.price && (
<div className="card-price">{formatPrice(item.price)}</div>
)}
</div>
</div>
)
}
const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed
return (
<div
key={item.id}
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
onClick={() => handleItemClick(item)}
>
<button
className="card-menu-button"
onClick={(e) => handleMenuClick(item, e)}
title="Меню"
>
</button>
<div className="card-image">
{item.image_url ? (
<img src={item.image_url} alt={item.name} />
) : (
<div className="placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
)}
</div>
<div className="card-name">{item.name}</div>
{isFaded && !item.completed ? (
renderUnlockCondition(item)
) : (
item.price && <div className="card-price">{formatPrice(item.price)}</div>
)}
</div>
)
}
if (loading) {
return (
<div className="wishlist">
<div className="fixed inset-0 bottom-20 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>
</div>
)
}
if (error) {
return (
<div className="wishlist">
<LoadingError onRetry={() => fetchWishlist()} />
</div>
)
}
return (
<div className="wishlist">
{/* Кнопка добавления */}
<button onClick={handleAddClick} className="add-wishlist-button">
Добавить
</button>
{/* Основной список (разблокированные и заблокированные вместе) */}
{items.length > 0 && (
<div className="wishlist-grid">
{items.map(renderItem)}
</div>
)}
{/* Завершённые */}
<div className="section-divider">
<button
className="completed-toggle"
onClick={handleToggleCompleted}
>
<span className="completed-toggle-icon">
{completedExpanded ? '▼' : '▶'}
</span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<>
{completedLoading ? (
<div className="loading-completed">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="wishlist-grid">
{completed.map(renderItem)}
</div>
)}
</>
)}
{/* Модальное окно для действий */}
{selectedItem && (
<div className="wishlist-modal-overlay" onClick={() => setSelectedItem(null)}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{selectedItem.name}</h3>
</div>
<div className="wishlist-modal-actions">
<button className="wishlist-modal-edit" onClick={handleEdit}>
Редактировать
</button>
{!selectedItem.completed && selectedItem.unlocked && (
<button className="wishlist-modal-complete" onClick={handleComplete}>
Завершить
</button>
)}
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default Wishlist

View File

@@ -0,0 +1,235 @@
.wishlist-detail {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.wishlist-detail h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.wishlist-detail-content {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.wishlist-detail-image {
width: 100%;
aspect-ratio: 5 / 6;
border-radius: 12px;
overflow: hidden;
margin-bottom: 1rem;
background: #f0f0f0;
}
.wishlist-detail-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wishlist-detail-price {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1rem;
}
.wishlist-detail-link {
margin-bottom: 1rem;
}
.wishlist-detail-link a {
color: #3498db;
text-decoration: none;
font-size: 1rem;
transition: color 0.2s;
}
.wishlist-detail-link a:hover {
color: #2980b9;
text-decoration: underline;
}
.wishlist-detail-conditions {
margin-bottom: 1.5rem;
}
.wishlist-detail-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.75rem 0;
}
.wishlist-detail-condition {
padding: 0.75rem 0;
font-size: 0.95rem;
}
.wishlist-detail-condition.met {
color: #27ae60;
}
.wishlist-detail-condition.not-met {
color: #888;
}
.condition-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.condition-icon {
flex-shrink: 0;
}
.condition-text {
flex: 1;
}
.condition-progress {
margin-top: 0.5rem;
margin-left: calc(16px + 0.5rem);
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.progress-fill {
height: 100%;
background-color: #3498db;
border-radius: 4px;
transition: width 0.3s ease;
}
.wishlist-detail-condition.met .progress-fill {
background-color: #27ae60;
}
.progress-text {
font-size: 0.85rem;
color: #666;
}
.wishlist-detail-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.5rem;
}
.wishlist-detail-edit-button,
.wishlist-detail-complete-button,
.wishlist-detail-uncomplete-button,
.wishlist-detail-delete-button {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.wishlist-detail-edit-button {
background-color: #3498db;
color: white;
}
.wishlist-detail-edit-button:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.wishlist-detail-complete-button {
background-color: #27ae60;
color: white;
}
.wishlist-detail-complete-button:hover:not(:disabled) {
background-color: #229954;
transform: translateY(-1px);
}
.wishlist-detail-complete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wishlist-detail-uncomplete-button {
background-color: #f39c12;
color: white;
}
.wishlist-detail-uncomplete-button:hover:not(:disabled) {
background-color: #e67e22;
transform: translateY(-1px);
}
.wishlist-detail-uncomplete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wishlist-detail-delete-button {
background-color: #e74c3c;
color: white;
}
.wishlist-detail-delete-button:hover:not(:disabled) {
background-color: #c0392b;
transform: translateY(-1px);
}
.wishlist-detail-delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 2rem;
color: #888;
}

View File

@@ -0,0 +1,302 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './WishlistDetail.css'
const API_URL = '/api/wishlist'
function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
const { authFetch } = useAuth()
const [wishlistItem, setWishlistItem] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingWishlist, setLoadingWishlist] = useState(true)
const [error, setError] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const fetchWishlistDetail = useCallback(async () => {
try {
setLoadingWishlist(true)
setLoading(true)
setError(null)
const response = await authFetch(`${API_URL}/${wishlistId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки желания')
}
const data = await response.json()
setWishlistItem(data)
} catch (err) {
setError(err.message)
console.error('Error fetching wishlist detail:', err)
} finally {
setLoading(false)
setLoadingWishlist(false)
}
}, [wishlistId, authFetch])
useEffect(() => {
if (wishlistId) {
fetchWishlistDetail()
} else {
setWishlistItem(null)
setLoading(true)
setLoadingWishlist(true)
setError(null)
}
}, [wishlistId, fetchWishlistDetail])
const handleEdit = () => {
onNavigate?.('wishlist-form', { wishlistId: wishlistId })
}
const handleComplete = async () => {
if (!wishlistItem || !wishlistItem.unlocked) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/complete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при завершении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error completing wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleUncomplete = async () => {
if (!wishlistItem || !wishlistItem.completed) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/uncomplete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при отмене завершения')
}
if (onRefresh) {
onRefresh()
}
fetchWishlistDetail()
} catch (err) {
console.error('Error uncompleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при отмене завершения', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleDelete = async () => {
if (!wishlistItem) return
if (!window.confirm('Вы уверены, что хотите удалить это желание?')) {
return
}
setIsDeleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error deleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
} finally {
setIsDeleting(false)
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const renderUnlockConditions = () => {
if (!wishlistItem || !wishlistItem.unlock_conditions || wishlistItem.unlock_conditions.length === 0) {
return null
}
return (
<div className="wishlist-detail-conditions">
<h3 className="wishlist-detail-section-title">Условия разблокировки:</h3>
{wishlistItem.unlock_conditions.map((condition, index) => {
let conditionText = ''
let progress = null
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
const isCompleted = condition.task_completed === true
progress = {
type: 'task',
completed: isCompleted
}
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const project = condition.project_name || 'Проект'
let period = ''
if (condition.period_type) {
const periodLabels = {
week: 'за неделю',
month: 'за месяц',
year: 'за год',
}
period = ' ' + periodLabels[condition.period_type] || ''
}
conditionText = `${requiredPoints} в ${project}${period}`
progress = {
type: 'points',
current: currentPoints,
required: requiredPoints,
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
}
}
const isMet = wishlistItem.unlocked || (progress?.type === 'task' && progress.completed) ||
(progress?.type === 'points' && progress.current >= progress.required)
return (
<div key={index} className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'}`}>
<div className="condition-header">
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
{isMet ? (
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
) : (
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
)}
</svg>
<span className="condition-text">{conditionText}</span>
</div>
{progress && progress.type === 'points' && !isMet && (
<div className="condition-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress.percentage}%` }}
></div>
</div>
<div className="progress-text">
{Math.round(progress.current)} / {Math.round(progress.required)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
if (loadingWishlist) {
return (
<div className="wishlist-detail">
<div className="fixed inset-0 bottom-20 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>
</div>
)
}
return (
<div className="wishlist-detail">
<button className="close-x-button" onClick={() => onNavigate?.('wishlist')}>
</button>
<h2>{wishlistItem ? wishlistItem.name : 'Желание'}</h2>
<div className="wishlist-detail-content">
{error && (
<LoadingError onRetry={fetchWishlistDetail} />
)}
{!error && wishlistItem && (
<>
{/* Изображение */}
{wishlistItem.image_url && (
<div className="wishlist-detail-image">
<img src={wishlistItem.image_url} alt={wishlistItem.name} />
</div>
)}
{/* Цена */}
{wishlistItem.price && (
<div className="wishlist-detail-price">
{formatPrice(wishlistItem.price)}
</div>
)}
{/* Ссылка */}
{wishlistItem.link && (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
Открыть ссылку
</a>
</div>
)}
{/* Условия разблокировки */}
{renderUnlockConditions()}
{/* Кнопка завершения */}
{wishlistItem.unlocked && !wishlistItem.completed && (
<div className="wishlist-detail-actions">
<button
onClick={handleComplete}
disabled={isCompleting}
className="wishlist-detail-complete-button"
>
{isCompleting ? 'Завершение...' : 'Завершить'}
</button>
</div>
)}
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default WishlistDetail

View File

@@ -0,0 +1,385 @@
.wishlist-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.wishlist-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.wishlist-form form {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.image-preview {
position: relative;
width: 100%;
max-width: 400px;
margin-top: 0.5rem;
}
.image-preview img {
width: 100%;
height: auto;
border-radius: 0.375rem;
aspect-ratio: 5 / 6;
object-fit: cover;
}
.remove-image-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(231, 76, 60, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.remove-image-button:hover {
background: rgba(192, 57, 43, 1);
}
.cropper-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.cropper-container {
position: relative;
width: 100%;
max-width: 600px;
height: 450px;
background: white;
border-radius: 0.5rem;
overflow: hidden;
}
.cropper-controls {
margin-top: 1rem;
background: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 600px;
}
.cropper-controls label {
display: flex;
align-items: center;
gap: 1rem;
color: white;
}
.cropper-controls input[type="range"] {
flex: 1;
}
.cropper-actions {
margin-top: 1rem;
display: flex;
gap: 1rem;
width: 100%;
max-width: 600px;
}
.cropper-actions button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cropper-actions button:first-child {
background: #6b7280;
color: white;
}
.cropper-actions button:first-child:hover {
background: #4b5563;
}
.cropper-actions button:last-child {
background: #3498db;
color: white;
}
.cropper-actions button:last-child:hover {
background: #2980b9;
}
.conditions-list {
margin-bottom: 0.5rem;
}
.condition-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
}
.remove-condition-button {
background: #e74c3c;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.remove-condition-button:hover {
background: #c0392b;
}
.add-condition-button {
width: 100%;
padding: 0.75rem;
background: #f3f4f6;
border: 1px dashed #9ca3af;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
color: #374151;
transition: all 0.2s;
}
.add-condition-button:hover {
background: #e5e7eb;
border-color: #6b7280;
}
.condition-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1500;
}
.condition-form {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.condition-form h3 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.submit-button {
flex: 1;
padding: 0.75rem;
background: #3498db;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.submit-button:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-button {
flex: 1;
padding: 0.75rem;
background: #6b7280;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button:hover {
background: #4b5563;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 0.375rem;
padding: 0.75rem;
margin-bottom: 1rem;
}
/* Link input with pull button */
.link-input-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.link-input-wrapper .form-input {
flex: 1;
}
.pull-metadata-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #3498db;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.pull-metadata-button:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
.pull-metadata-button:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
}
.pull-metadata-button svg {
width: 20px;
height: 20px;
}
.mini-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,682 @@
import React, { useState, useEffect, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import './WishlistForm.css'
const API_URL = '/api/wishlist'
const TASKS_API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
function WishlistForm({ onNavigate, wishlistId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [link, setLink] = useState('')
const [imageUrl, setImageUrl] = useState(null)
const [imageFile, setImageFile] = useState(null)
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 [tasks, setTasks] = useState([])
const [projects, setProjects] = 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)
// Загрузка задач и проектов
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 : [])
}
// Загружаем проекты
const projectsResponse = await authFetch(PROJECTS_API_URL)
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json()
setProjects(Array.isArray(projectsData) ? projectsData : [])
}
} catch (err) {
console.error('Error loading data:', err)
}
}
loadData()
}, [])
// Загрузка желания при редактировании
useEffect(() => {
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
loadWishlist()
} else if (wishlistId === undefined || wishlistId === null) {
resetForm()
}
}, [wishlistId, tasks, projects])
const loadWishlist = async () => {
setLoadingWishlist(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки желания')
}
const data = await response.json()
setName(data.name || '')
setPrice(data.price ? String(data.price) : '')
setLink(data.link || '')
setImageUrl(data.image_url || null)
if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
type: cond.type,
task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null,
project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null,
required_points: cond.required_points || null,
period_type: cond.period_type || null,
display_order: idx,
})))
}
} catch (err) {
setError(err.message)
} finally {
setLoadingWishlist(false)
}
}
const resetForm = () => {
setName('')
setPrice('')
setLink('')
setImageUrl(null)
setImageFile(null)
setUnlockConditions([])
setError('')
}
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
const fetchLinkMetadata = useCallback(async () => {
if (!link || !link.trim()) {
setToastMessage({ text: 'Введите ссылку', type: 'error' })
return
}
// Проверяем валидность URL
try {
new URL(link)
} 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: link.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 {
// Загружаем изображение напрямую
const imgResponse = await fetch(metadata.image)
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)
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 {
setToastMessage({ text: 'Не удалось загрузить информацию', type: 'error' })
}
} catch (err) {
console.error('Error fetching metadata:', err)
setToastMessage({ text: 'Ошибка при загрузке информации', 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)
setShowCropper(true)
}
reader.readAsDataURL(file)
}
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)
setShowCropper(false)
}
reader.readAsDataURL(croppedImage)
} catch (err) {
setToastMessage({ text: 'Ошибка при обрезке изображения', type: 'error' })
}
}
const handleAddCondition = () => {
setShowConditionForm(true)
}
const handleConditionSubmit = (condition) => {
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
setShowConditionForm(false)
}
const handleRemoveCondition = (index) => {
setUnlockConditions(unlockConditions.filter((_, i) => i !== index))
}
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,
unlock_conditions: unlockConditions.map(cond => ({
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,
period_type: cond.type === 'project_points' ? cond.period_type : null,
})),
}
const url = wishlistId ? `${API_URL}/${wishlistId}` : API_URL
const method = wishlistId ? 'PUT' : '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 (imageFile && itemId) {
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' })
}
}
resetForm()
onNavigate?.('wishlist')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
onNavigate?.('wishlist')
}
if (loadingWishlist) {
return (
<div className="wishlist-form">
<div className="fixed inset-0 bottom-20 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>
</div>
)
}
return (
<div className="wishlist-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="link">Ссылка</label>
<div className="link-input-wrapper">
<input
id="link"
type="url"
value={link}
onChange={(e) => setLink(e.target.value)}
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>Картинка</label>
{imageUrl && !showCropper && (
<div className="image-preview">
<img src={imageUrl} alt="Preview" />
<button
type="button"
onClick={() => {
setImageUrl(null)
setImageFile(null)
}}
className="remove-image-button"
>
</button>
</div>
)}
{!imageUrl && (
<input
type="file"
accept="image/*"
onChange={handleImageSelect}
className="form-input"
/>
)}
</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) => (
<div key={idx} className="condition-item">
<span>
{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.period_type ? ` за ${cond.period_type === 'week' ? 'неделю' : cond.period_type === 'month' ? 'месяц' : 'год'}` : ''}`}
</span>
<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>}
<div className="form-actions">
<button type="submit" disabled={loading} className="submit-button">
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
{showConditionForm && (
<ConditionForm
tasks={tasks}
projects={projects}
onSubmit={handleConditionSubmit}
onCancel={() => setShowConditionForm(false)}
/>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
// Компонент формы условия разблокировки
function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
const [type, setType] = useState('task_completion')
const [taskId, setTaskId] = useState('')
const [projectId, setProjectId] = useState('')
const [requiredPoints, setRequiredPoints] = useState('')
const [periodType, setPeriodType] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
e.stopPropagation() // Предотвращаем всплытие события
// Валидация
if (type === 'task_completion' && !taskId) {
return
}
if (type === 'project_points' && (!projectId || !requiredPoints)) {
return
}
const condition = {
type,
task_id: type === 'task_completion' ? parseInt(taskId) : null,
project_id: type === 'project_points' ? parseInt(projectId) : null,
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
period_type: type === 'project_points' && periodType ? periodType : null,
}
onSubmit(condition)
// Сброс формы
setType('task_completion')
setTaskId('')
setProjectId('')
setRequiredPoints('')
setPeriodType('')
}
return (
<div className="condition-form-overlay" onClick={onCancel}>
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
<h3>Добавить условие разблокировки</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Тип условия</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="form-input"
>
<option value="task_completion">Выполнить задачу</option>
<option value="project_points">Набрать баллы в проекте</option>
</select>
</div>
{type === 'task_completion' && (
<div className="form-group">
<label>Задача</label>
<select
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="form-input"
required
>
<option value="">Выберите задачу</option>
{tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</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>
<select
value={periodType}
onChange={(e) => setPeriodType(e.target.value)}
className="form-input"
>
<option value="">За всё время</option>
<option value="week">За неделю</option>
<option value="month">За месяц</option>
<option value="year">За год</option>
</select>
</div>
</>
)}
<div className="form-actions">
<button type="button" onClick={onCancel} className="cancel-button">
Отмена
</button>
<button type="submit" className="submit-button">
Добавить
</button>
</div>
</form>
</div>
</div>
)
}
export default WishlistForm

View File

@@ -254,9 +254,17 @@ export function AuthProvider({ children }) {
const authFetch = useCallback(async (url, options = {}) => {
const token = localStorage.getItem(TOKEN_KEY)
const headers = {
'Content-Type': 'application/json',
...options.headers
// Не устанавливаем Content-Type для FormData - браузер сделает это автоматически
const isFormData = options.body instanceof FormData
const headers = {}
if (!isFormData && !options.headers?.['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
// Добавляем пользовательские заголовки
if (options.headers) {
Object.assign(headers, options.headers)
}
if (token) {