Доски желаний и политика награждения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s

This commit is contained in:
poignatov
2026-01-13 22:35:01 +03:00
parent 5ebb55510e
commit f9928c6470
19 changed files with 3662 additions and 198 deletions

View File

@@ -1 +1 @@
3.12.2 3.13.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
-- Migration: Add wishlist boards for multi-user collaboration
-- Each user can have multiple boards, share them via invite links,
-- and collaborate with other users on shared wishes
-- ============================================
-- Table: wishlist_boards (доски желаний)
-- ============================================
CREATE TABLE IF NOT EXISTS wishlist_boards (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
-- Настройки доступа по ссылке
invite_token VARCHAR(64) UNIQUE,
invite_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_id ON wishlist_boards(owner_id);
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_invite_token ON wishlist_boards(invite_token)
WHERE invite_token IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted);
-- ============================================
-- Table: wishlist_board_members (участники доски)
-- ============================================
CREATE TABLE IF NOT EXISTS wishlist_board_members (
id SERIAL PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES wishlist_boards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_board_member UNIQUE (board_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_board_members_board_id ON wishlist_board_members(board_id);
CREATE INDEX IF NOT EXISTS idx_board_members_user_id ON wishlist_board_members(user_id);
-- ============================================
-- Modify: wishlist_items - добавляем board_id и author_id
-- ============================================
ALTER TABLE wishlist_items
ADD COLUMN IF NOT EXISTS board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE;
ALTER TABLE wishlist_items
ADD COLUMN IF NOT EXISTS author_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_wishlist_items_board_id ON wishlist_items(board_id);
CREATE INDEX IF NOT EXISTS idx_wishlist_items_author_id ON wishlist_items(author_id);
-- ============================================
-- Modify: wishlist_conditions - добавляем user_id для персональных целей
-- ============================================
ALTER TABLE wishlist_conditions
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_user_id ON wishlist_conditions(user_id);
-- ============================================
-- Modify: tasks - добавляем политику награждения для wishlist задач
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
COMMENT ON COLUMN tasks.reward_policy IS
'For wishlist tasks: personal = only if user completes, shared = anyone completes';
-- ============================================
-- Миграция данных: Этап 1 - создаём персональные доски
-- ============================================
-- Создаём доску "Мои желания" для каждого пользователя с желаниями
INSERT INTO wishlist_boards (owner_id, name)
SELECT DISTINCT user_id, 'Мои желания'
FROM wishlist_items
WHERE user_id IS NOT NULL
AND deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM wishlist_boards wb
WHERE wb.owner_id = wishlist_items.user_id AND wb.name = 'Мои желания'
);
-- ============================================
-- Миграция данных: Этап 2 - привязываем желания к доскам
-- ============================================
UPDATE wishlist_items wi
SET
board_id = wb.id,
author_id = COALESCE(wi.author_id, wi.user_id)
FROM wishlist_boards wb
WHERE wi.board_id IS NULL
AND wi.user_id = wb.owner_id
AND wb.name = 'Мои желания';
-- ============================================
-- Миграция данных: Этап 3 - заполняем user_id в условиях
-- ============================================
UPDATE wishlist_conditions wc
SET user_id = wi.user_id
FROM wishlist_items wi
WHERE wc.wishlist_item_id = wi.id
AND wc.user_id IS NULL;
-- ============================================
-- Comments
-- ============================================
COMMENT ON TABLE wishlist_boards IS 'Wishlist boards for organizing and sharing wishes';
COMMENT ON COLUMN wishlist_boards.invite_token IS 'Token for invite link, NULL = disabled';
COMMENT ON COLUMN wishlist_boards.invite_enabled IS 'Whether invite link is active';
COMMENT ON TABLE wishlist_board_members IS 'Users who joined boards via invite link (not owners)';
COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards.';
COMMENT ON COLUMN wishlist_items.author_id IS 'User who created this item (may differ from board owner on shared boards)';
COMMENT ON COLUMN wishlist_items.board_id IS 'Board this item belongs to';

View File

@@ -0,0 +1,13 @@
-- Migration: Add reward_policy to tasks table
-- This migration adds reward_policy column for wishlist tasks
-- If the column already exists (from migration 023), this will be a no-op
-- ============================================
-- Modify: tasks - добавляем политику награждения для wishlist задач
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
COMMENT ON COLUMN tasks.reward_policy IS
'For wishlist tasks: personal = only if user completes, shared = anyone completes';

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.12.2", "version": "3.13.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -12,6 +12,8 @@ import TaskForm from './components/TaskForm.jsx'
import Wishlist from './components/Wishlist' import Wishlist from './components/Wishlist'
import WishlistForm from './components/WishlistForm' import WishlistForm from './components/WishlistForm'
import WishlistDetail from './components/WishlistDetail' import WishlistDetail from './components/WishlistDetail'
import BoardForm from './components/BoardForm'
import BoardJoinPreview from './components/BoardJoinPreview'
import TodoistIntegration from './components/TodoistIntegration' import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration' import TelegramIntegration from './components/TelegramIntegration'
import { AuthProvider, useAuth } from './components/auth/AuthContext' import { AuthProvider, useAuth } from './components/auth/AuthContext'
@@ -24,7 +26,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком) // Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
function AppContent() { function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -57,6 +59,8 @@ function AppContent() {
wishlist: false, wishlist: false,
'wishlist-form': false, 'wishlist-form': false,
'wishlist-detail': false, 'wishlist-detail': false,
'board-form': false,
'board-join': false,
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
@@ -76,6 +80,8 @@ function AppContent() {
wishlist: false, wishlist: false,
'wishlist-form': false, 'wishlist-form': false,
'wishlist-detail': false, 'wishlist-detail': false,
'board-form': false,
'board-join': false,
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
@@ -122,10 +128,25 @@ function AppContent() {
if (isInitialized) return if (isInitialized) return
try { try {
// Проверяем путь /invite/:token для присоединения к доске
const path = window.location.pathname
if (path.startsWith('/invite/')) {
const token = path.replace('/invite/', '')
if (token) {
setActiveTab('board-join')
setLoadedTabs(prev => ({ ...prev, 'board-join': true }))
setTabParams({ inviteToken: token })
setIsInitialized(true)
// Очищаем путь, оставляем только параметры
window.history.replaceState({}, '', '/?tab=board-join&inviteToken=' + token)
return
}
}
// Проверяем URL только для глубоких табов // Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab') const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его // Если в URL есть глубокий таб, восстанавливаем его
@@ -616,7 +637,7 @@ function AppContent() {
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания) // task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && params.boardId === undefined
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) { if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
setTabParams({}) setTabParams({})
if (isNewTabMain) { if (isNewTabMain) {
@@ -655,7 +676,12 @@ function AppContent() {
} }
// Обновляем список желаний при возврате из экрана редактирования // Обновляем список желаний при возврате из экрана редактирования
if (activeTab === 'wishlist-form' && tab === 'wishlist') { if (activeTab === 'wishlist-form' && tab === 'wishlist') {
setTabParams({}) // Очищаем параметры при закрытии формы // Сохраняем boardId из параметров или текущих tabParams
const savedBoardId = params.boardId || tabParams.boardId
// Параметры уже установлены в строке 649, но мы можем их обновить, чтобы сохранить boardId
if (savedBoardId) {
setTabParams(prev => ({ ...prev, boardId: savedBoardId }))
}
setWishlistRefreshTrigger(prev => prev + 1) setWishlistRefreshTrigger(prev => prev + 1)
} }
// Загрузка данных произойдет в useEffect при изменении activeTab // Загрузка данных произойдет в useEffect при изменении activeTab
@@ -859,6 +885,8 @@ function AppContent() {
onNavigate={handleNavigate} onNavigate={handleNavigate}
refreshTrigger={wishlistRefreshTrigger} refreshTrigger={wishlistRefreshTrigger}
isActive={activeTab === 'wishlist'} isActive={activeTab === 'wishlist'}
initialBoardId={tabParams.boardId}
boardDeleted={tabParams.boardDeleted}
/> />
</div> </div>
)} )}
@@ -866,11 +894,12 @@ function AppContent() {
{loadedTabs['wishlist-form'] && ( {loadedTabs['wishlist-form'] && (
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}> <div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
<WishlistForm <WishlistForm
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}`} key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}-${tabParams.boardId ?? ''}`}
onNavigate={handleNavigate} onNavigate={handleNavigate}
wishlistId={tabParams.wishlistId} wishlistId={tabParams.wishlistId}
editConditionIndex={tabParams.editConditionIndex} editConditionIndex={tabParams.editConditionIndex}
newTaskId={tabParams.newTaskId} newTaskId={tabParams.newTaskId}
boardId={tabParams.boardId}
/> />
</div> </div>
)} )}
@@ -886,6 +915,27 @@ function AppContent() {
</div> </div>
)} )}
{loadedTabs['board-form'] && (
<div className={activeTab === 'board-form' ? 'block' : 'hidden'}>
<BoardForm
key={tabParams.boardId || 'new'}
onNavigate={handleNavigate}
boardId={tabParams.boardId}
onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)}
/>
</div>
)}
{loadedTabs['board-join'] && (
<div className={activeTab === 'board-join' ? 'block' : 'hidden'}>
<BoardJoinPreview
key={tabParams.inviteToken}
onNavigate={handleNavigate}
inviteToken={tabParams.inviteToken}
/>
</div>
)}
{loadedTabs.profile && ( {loadedTabs.profile && (
<div className={activeTab === 'profile' ? 'block' : 'hidden'}> <div className={activeTab === 'profile' ? 'block' : 'hidden'}>
<Profile onNavigate={handleNavigate} /> <Profile onNavigate={handleNavigate} />

View File

@@ -0,0 +1,170 @@
.board-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.board-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.form-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.form-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
/* Toggle switch */
.toggle-field {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-field input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 48px;
height: 26px;
background: #d1d5db;
border-radius: 13px;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-field input:checked + .toggle-slider {
background: #6366f1;
}
.toggle-field input:checked + .toggle-slider::after {
transform: translateX(22px);
}
.toggle-label {
font-size: 0.95rem;
color: #374151;
}
/* Invite link section */
.invite-link-section {
margin-top: 1rem;
}
.invite-url-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.invite-url-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
background: #f9fafb;
color: #374151;
font-family: monospace;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1.1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.copy-btn:hover {
background: #4f46e5;
}
.regenerate-btn {
width: 100%;
padding: 10px;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.regenerate-btn:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.invite-hint {
margin-top: 8px;
font-size: 0.85rem;
color: #6b7280;
}
/* Delete button */
.delete-board-btn {
display: block;
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background: transparent;
border: 1px solid #fecaca;
border-radius: 8px;
color: #ef4444;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.delete-board-btn:hover {
background: #fef2f2;
border-color: #ef4444;
}

View File

@@ -0,0 +1,279 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers'
import Toast from './Toast'
import './BoardForm.css'
function BoardForm({ boardId, onNavigate, onSaved }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false)
const [inviteURL, setInviteURL] = useState('')
const [loading, setLoading] = useState(false)
const [loadingBoard, setLoadingBoard] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const isEdit = !!boardId
useEffect(() => {
if (boardId) {
fetchBoard()
}
}, [boardId])
const fetchBoard = async () => {
setLoadingBoard(true)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`)
if (res.ok) {
const data = await res.json()
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
setLoadingBoard(false)
}
}
const handleSave = async () => {
if (!name.trim()) {
setToastMessage({ text: 'Введите название доски', type: 'error' })
return
}
setLoading(true)
try {
const url = boardId
? `/api/wishlist/boards/${boardId}`
: '/api/wishlist/boards'
const res = await authFetch(url, {
method: boardId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
invite_enabled: inviteEnabled
})
})
if (res.ok) {
const data = await res.json()
if (data.invite_url) {
setInviteURL(data.invite_url)
}
onSaved?.()
if (!boardId) {
// При создании возвращаемся назад
onNavigate('wishlist', { boardId: data.id })
} else {
// При редактировании возвращаемся на доску
onNavigate('wishlist', { boardId: boardId })
}
} else {
const err = await res.json()
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
setLoading(false)
}
}
const handleRegenerateLink = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
setInviteURL(data.invite_url)
setInviteEnabled(true)
setToastMessage({ text: 'Ссылка обновлена', type: 'success' })
} else {
setToastMessage({ text: 'Ошибка обновления ссылки', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
}
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteURL)
setCopied(true)
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setTimeout(() => setCopied(false), 2000)
}
const handleToggleInvite = async (enabled) => {
setInviteEnabled(enabled)
if (boardId && enabled && !inviteURL) {
// Автоматически генерируем ссылку при включении
await handleRegenerateLink()
} else if (boardId) {
// Просто обновляем статус
try {
await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_enabled: enabled })
})
} catch (err) {
console.error('Error updating invite status:', err)
}
}
}
const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'DELETE'
})
if (res.ok) {
onSaved?.()
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
onNavigate('wishlist', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
}
const handleClose = () => {
onNavigate('wishlist')
}
if (loadingBoard) {
return (
<div className="board-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="board-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
<div className="form-card">
<div className="form-group">
<label htmlFor="board-name">Название</label>
<input
id="board-name"
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Название доски"
/>
</div>
{isEdit && (
<>
{/* Настройки доступа */}
<div className="form-section">
<h3>Доступ по ссылке</h3>
<label className="toggle-field">
<input
type="checkbox"
checked={inviteEnabled}
onChange={e => handleToggleInvite(e.target.checked)}
/>
<span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span>
</label>
{inviteEnabled && inviteURL && (
<div className="invite-link-section">
<div className="invite-url-row">
<input
type="text"
className="invite-url-input"
value={inviteURL}
readOnly
/>
<button
className="copy-btn"
onClick={handleCopyLink}
title="Копировать ссылку"
>
{copied ? '✓' : '📋'}
</button>
</div>
<button
className="regenerate-btn"
onClick={handleRegenerateLink}
>
🔄 Перегенерировать ссылку
</button>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске
</p>
</div>
)}
</div>
{/* Список участников */}
<BoardMembers
boardId={boardId}
onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
</>
)}
<div className="form-actions">
<button className="cancel-button" onClick={handleClose}>
Отмена
</button>
<button
className="submit-button"
onClick={handleSave}
disabled={loading || !name.trim()}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{isEdit && (
<button className="delete-board-btn" onClick={handleDelete}>
🗑 Удалить доску
</button>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default BoardForm

View File

@@ -0,0 +1,199 @@
.board-join-preview {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: white;
}
.preview-loading p {
margin: 0;
font-size: 1.1rem;
}
.preview-card {
background: white;
border-radius: 24px;
padding: 2rem;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.preview-card.error-card {
max-width: 360px;
}
.invite-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.preview-card h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.board-info {
background: #f9fafb;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.board-name {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.75rem;
}
.board-owner,
.board-members {
display: flex;
justify-content: center;
gap: 0.5rem;
font-size: 0.95rem;
color: #6b7280;
margin-top: 0.5rem;
}
.board-owner .value,
.board-members .value {
color: #374151;
font-weight: 500;
}
.error-text {
color: #ef4444;
margin: 0 0 1.5rem 0;
}
.join-error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #ef4444;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.join-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.join-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4);
}
.join-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.spinner-small {
width: 18px;
height: 18px;
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);
}
}
.login-prompt {
text-align: center;
}
.login-prompt p {
color: #6b7280;
margin: 0 0 1rem 0;
font-size: 0.95rem;
}
.login-btn {
width: 100%;
padding: 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover {
background: #4f46e5;
}
.back-btn {
width: 100%;
padding: 1rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
background: #e5e7eb;
}
.cancel-link {
margin-top: 1rem;
background: none;
border: none;
color: #9ca3af;
font-size: 0.95rem;
cursor: pointer;
transition: color 0.2s;
}
.cancel-link:hover {
color: #6b7280;
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardJoinPreview.css'
function BoardJoinPreview({ inviteToken, onNavigate }) {
const { authFetch, user } = useAuth()
const [board, setBoard] = useState(null)
const [loading, setLoading] = useState(true)
const [joining, setJoining] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (inviteToken) {
fetchBoardInfo()
}
}, [inviteToken])
const fetchBoardInfo = async () => {
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}`)
if (res.ok) {
setBoard(await res.json())
} else {
const err = await res.json()
setError(err.error || 'Ссылка недействительна или устарела')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleJoin = async () => {
if (!user) {
// Сохраняем токен для возврата после логина
sessionStorage.setItem('pendingInviteToken', inviteToken)
onNavigate('login')
return
}
setJoining(true)
setError('')
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}/join`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
// Переходим на доску
onNavigate('wishlist', { boardId: data.board.id })
} else {
const err = await res.json()
setError(err.error || 'Ошибка при присоединении')
}
} catch (err) {
setError('Ошибка при присоединении')
} finally {
setJoining(false)
}
}
const handleGoBack = () => {
onNavigate('wishlist')
}
if (loading) {
return (
<div className="board-join-preview">
<div className="preview-loading">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !board) {
return (
<div className="board-join-preview">
<div className="preview-card error-card">
<div className="error-icon"></div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="back-btn" onClick={handleGoBack}>
Вернуться к желаниям
</button>
</div>
</div>
)
}
return (
<div className="board-join-preview">
<div className="preview-card">
<div className="invite-icon"></div>
<h2>Приглашение на доску</h2>
<div className="board-info">
<div className="board-name">{board.name}</div>
<div className="board-owner">
<span className="label">Владелец:</span>
<span className="value">{board.owner_name}</span>
</div>
{board.member_count > 0 && (
<div className="board-members">
<span className="label">Участников:</span>
<span className="value">{board.member_count}</span>
</div>
)}
</div>
{error && (
<div className="join-error">{error}</div>
)}
{user ? (
<button
className="join-btn"
onClick={handleJoin}
disabled={joining}
>
{joining ? (
<>
<span className="spinner-small"></span>
<span>Присоединение...</span>
</>
) : (
<>
<span>🎉</span>
<span>Присоединиться</span>
</>
)}
</button>
) : (
<div className="login-prompt">
<p>Для присоединения необходимо войти в аккаунт</p>
<button className="login-btn" onClick={() => onNavigate('login')}>
Войти
</button>
</div>
)}
<button className="cancel-link" onClick={handleGoBack}>
Отмена
</button>
</div>
</div>
)
}
export default BoardJoinPreview

View File

@@ -0,0 +1,132 @@
.board-members-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.board-members-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.members-loading {
padding: 1rem;
text-align: center;
color: #9ca3af;
}
.no-members {
padding: 1.5rem;
text-align: center;
background: #f9fafb;
border-radius: 8px;
}
.no-members p {
margin: 0;
color: #6b7280;
}
.no-members .hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #9ca3af;
}
.members-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
transition: background 0.15s;
}
.member-item:hover {
background: #f3f4f6;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-weight: 500;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-date {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 2px;
}
.remove-member-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid #e5e7eb;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-member-btn:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
color: #ef4444;
}
.remove-member-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner-small {
width: 14px;
height: 14px;
border: 2px solid #d1d5db;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardMembers.css'
function BoardMembers({ boardId, onMemberRemoved }) {
const { authFetch } = useAuth()
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(true)
const [removingId, setRemovingId] = useState(null)
useEffect(() => {
if (boardId) {
fetchMembers()
}
}, [boardId])
const fetchMembers = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`)
if (res.ok) {
const data = await res.json()
setMembers(data || [])
}
} catch (err) {
console.error('Error fetching members:', err)
} finally {
setLoading(false)
}
}
const handleRemoveMember = async (userId) => {
if (!window.confirm('Удалить участника из доски?')) return
setRemovingId(userId)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, {
method: 'DELETE'
})
if (res.ok) {
setMembers(members.filter(m => m.user_id !== userId))
onMemberRemoved?.()
}
} catch (err) {
console.error('Error removing member:', err)
} finally {
setRemovingId(null)
}
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
}
if (loading) {
return (
<div className="board-members-section">
<h3>Участники</h3>
<div className="members-loading">Загрузка...</div>
</div>
)
}
return (
<div className="board-members-section">
<h3>Участники ({members.length})</h3>
{members.length === 0 ? (
<div className="no-members">
<p>Пока никто не присоединился к доске</p>
<p className="hint">Поделитесь ссылкой, чтобы пригласить участников</p>
</div>
) : (
<div className="members-list">
{members.map(member => (
<div key={member.id} className="member-item">
<div className="member-avatar">
{(member.name || member.email).charAt(0).toUpperCase()}
</div>
<div className="member-info">
<div className="member-name">
{member.name || member.email}
</div>
<div className="member-date">
Присоединился {formatDate(member.joined_at)}
</div>
</div>
<button
className="remove-member-btn"
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingId === member.user_id}
title="Удалить участника"
>
{removingId === member.user_id ? (
<span className="spinner-small"></span>
) : (
'✕'
)}
</button>
</div>
))}
</div>
)}
</div>
)
}
export default BoardMembers

View File

@@ -0,0 +1,242 @@
.board-selector {
position: relative;
max-width: 42rem;
margin: 0 auto;
padding-top: 0;
padding-bottom: 16px;
}
/* Дополнительный отступ сверху на больших экранах, чтобы соответствовать кнопке "Добавить" на экране задач */
@media (min-width: 768px) {
.board-selector {
margin-top: 0.5rem; /* 8px - разница между md:p-8 (32px) и md:p-6 (24px) */
}
}
.board-header {
display: flex;
gap: 12px;
align-items: center;
}
/* Основная кнопка-pill */
.board-pill {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px;
background: white;
border: none;
border-radius: 26px;
font-size: 17px;
font-weight: 500;
color: #1f2937;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.board-pill:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.board-pill:active:not(:disabled) {
transform: translateY(0);
}
.board-pill.open {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2), 0 2px 4px rgba(0, 0, 0, 0.08);
}
.board-pill:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shared-icon {
font-size: 16px;
flex-shrink: 0;
}
.board-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: #9ca3af;
flex-shrink: 0;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.chevron.rotated {
transform: rotate(180deg);
}
/* Кнопка действия (настройки/выход) */
.board-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #6b7280;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.board-action-btn:hover {
background: #f9fafb;
color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.board-action-btn:active {
transform: translateY(0);
}
.board-action-btn svg {
width: 22px;
height: 22px;
}
/* Выпадающий список */
.board-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: white;
border-radius: 18px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
z-index: 100;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.98);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.board-dropdown.visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.dropdown-content {
padding: 10px;
}
.dropdown-list {
max-height: 280px;
overflow-y: auto;
}
.dropdown-empty {
padding: 28px 16px;
text-align: center;
color: #9ca3af;
font-size: 15px;
}
/* Элементы списка */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 16px;
border: none;
background: transparent;
border-radius: 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
color: #374151;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-item.selected {
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
color: #4f46e5;
}
.dropdown-item.selected:hover {
background: linear-gradient(135deg, #667eea18 0%, #764ba218 100%);
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.item-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.item-badge {
font-size: 14px;
}
.item-members {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 8px;
background: #e5e7eb;
border-radius: 13px;
font-size: 13px;
font-weight: 600;
color: #6b7280;
}
.check-icon {
color: #4f46e5;
}
/* Кнопка добавления доски */
.dropdown-item.add-board {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid #f3f4f6;
border-radius: 0 0 12px 12px;
color: #667eea;
font-weight: 500;
gap: 12px;
justify-content: flex-start;
}
.dropdown-item.add-board:hover {
background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
}
.dropdown-item.add-board svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect, useRef } from 'react'
import './BoardSelector.css'
function BoardSelector({
boards,
selectedBoardId,
onBoardChange,
onBoardEdit,
onAddBoard,
loading
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef(null)
const selectedBoard = boards.find(b => b.id === selectedBoardId)
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelectBoard = (board) => {
onBoardChange(board.id)
setIsOpen(false)
}
return (
<div className="board-selector" ref={dropdownRef}>
<div className="board-header">
<button
className={`board-pill ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen(!isOpen)}
disabled={loading}
>
{!selectedBoard?.is_owner && selectedBoard && (
<span className="shared-icon">👥</span>
)}
<span className="board-label">
{loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')}
</span>
<svg
className={`chevron ${isOpen ? 'rotated' : ''}`}
width="14"
height="14"
viewBox="0 0 12 12"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{selectedBoard && (
<button
className="board-action-btn"
onClick={onBoardEdit}
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
>
{selectedBoard.is_owner ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
<circle cx="5" cy="12" r="1.5"></circle>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)}
</button>
)}
</div>
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
<div className="dropdown-content">
{boards.length === 0 ? (
<div className="dropdown-empty">
Нет досок
</div>
) : (
<div className="dropdown-list">
{boards.map(board => (
<button
key={board.id}
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
onClick={() => handleSelectBoard(board)}
>
<span className="item-name">{board.name}</span>
<div className="item-meta">
{!board.is_owner && <span className="item-badge shared">👥</span>}
{board.member_count > 0 && (
<span className="item-members">{board.member_count}</span>
)}
{board.id === selectedBoardId && (
<svg className="check-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</div>
</button>
))}
</div>
)}
<button className="dropdown-item add-board" onClick={onAddBoard}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span>Создать доску</span>
</button>
</div>
</div>
</div>
)
}
export default BoardSelector

View File

@@ -24,6 +24,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
const [rewardPolicy, setRewardPolicy] = useState('personal') // Политика награждения: 'personal' или 'general'
// Test-specific state // Test-specific state
const [isTest, setIsTest] = useState(isTestFromProps) const [isTest, setIsTest] = useState(isTestFromProps)
const [wordsCount, setWordsCount] = useState('10') const [wordsCount, setWordsCount] = useState('10')
@@ -122,6 +123,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} else { } else {
setCurrentWishlistId(null) setCurrentWishlistId(null)
setWishlistInfo(null) setWishlistInfo(null)
setRewardPolicy('personal') // Сбрасываем при отвязке
} }
} }
}, [taskId, wishlistId, authFetch]) }, [taskId, wishlistId, authFetch])
@@ -339,9 +341,16 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} catch (err) { } catch (err) {
console.error('Error loading wishlist info:', err) console.error('Error loading wishlist info:', err)
} }
// Загружаем политику награждения
if (data.task.reward_policy) {
setRewardPolicy(data.task.reward_policy)
} else {
setRewardPolicy('personal') // Значение по умолчанию
}
} else { } else {
setCurrentWishlistId(null) setCurrentWishlistId(null)
setWishlistInfo(null) setWishlistInfo(null)
setRewardPolicy('personal') // Сбрасываем при отвязке
} }
// Загружаем информацию о тесте, если есть config_id // Загружаем информацию о тесте, если есть config_id
@@ -628,6 +637,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
wishlist_id: taskId wishlist_id: taskId
? (currentWishlistId && !wishlistInfo ? null : undefined) ? (currentWishlistId && !wishlistInfo ? null : undefined)
: (currentWishlistId || undefined), : (currentWishlistId || undefined),
reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined,
rewards: rewards.map(r => ({ rewards: rewards.map(r => ({
position: r.position, position: r.position,
project_name: r.project_name.trim(), project_name: r.project_name.trim(),
@@ -798,6 +808,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</button> </button>
)} )}
</div> </div>
<div className="form-group" style={{ marginTop: '12px' }}>
<label htmlFor="reward_policy">Политика награждения:</label>
<select
id="reward_policy"
value={rewardPolicy}
onChange={(e) => setRewardPolicy(e.target.value)}
className="form-input"
>
<option value="personal">Личная</option>
<option value="general">Общая</option>
</select>
<small style={{ color: '#666', fontSize: '0.9em', display: 'block', marginTop: '4px' }}>
{rewardPolicy === 'personal'
? 'Задача выполняется только если вы сами завершили желание. Если другой пользователь завершит желание, задача будет удалена.'
: 'Задача выполняется если кто-либо (неважно кто) отметил желание завершённым.'}
</small>
</div>
</div> </div>
)} )}

View File

@@ -4,6 +4,13 @@
padding-bottom: 5rem; padding-bottom: 5rem;
} }
.wishlist-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
.add-wishlist-button { .add-wishlist-button {
background: transparent; background: transparent;
border: 2px dashed #6b8dd6; border: 2px dashed #6b8dd6;

View File

@@ -1,18 +1,44 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import BoardSelector from './BoardSelector'
import LoadingError from './LoadingError' import LoadingError from './LoadingError'
import './Wishlist.css' import './Wishlist.css'
const API_URL = '/api/wishlist' const API_URL = '/api/wishlist'
const CACHE_KEY = 'wishlist_cache' const BOARDS_CACHE_KEY = 'wishlist_boards_cache'
const CACHE_COMPLETED_KEY = 'wishlist_completed_cache' const ITEMS_CACHE_KEY = 'wishlist_items_cache'
const SELECTED_BOARD_KEY = 'wishlist_selected_board_id'
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
// Восстанавливаем выбранную доску из localStorage или используем initialBoardId
const getInitialBoardId = () => {
if (initialBoardId) return initialBoardId
return getSavedBoardId()
}
// Получает сохранённую доску из localStorage
const getSavedBoardId = () => {
try {
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
if (saved) {
const boardId = parseInt(saved, 10)
if (!isNaN(boardId)) return boardId
}
} catch (err) {
console.error('Error loading selected board from cache:', err)
}
return null
}
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [completed, setCompleted] = useState([]) const [completed, setCompleted] = useState([])
const [completedCount, setCompletedCount] = useState(0) const [completedCount, setCompletedCount] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [boardsLoading, setBoardsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false) const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false) const [completedLoading, setCompletedLoading] = useState(false)
@@ -22,19 +48,66 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const initialFetchDoneRef = useRef(false) const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive) const prevIsActiveRef = useRef(isActive)
// Проверка наличия кэша // Обёртка для setSelectedBoardId с сохранением в localStorage
const hasCache = () => { const setSelectedBoardId = (boardId) => {
setSelectedBoardIdState(boardId)
try { try {
return localStorage.getItem(CACHE_KEY) !== null if (boardId) {
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
} else {
localStorage.removeItem(SELECTED_BOARD_KEY)
}
} catch (err) { } catch (err) {
return false console.error('Error saving selected board to cache:', err)
} }
} }
// Загрузка основных данных из кэша // Загрузка досок из кэша
const loadFromCache = () => { const loadBoardsFromCache = () => {
try { try {
const cached = localStorage.getItem(CACHE_KEY) const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
setBoards(data.boards || [])
// Проверяем, что сохранённая доска существует в списке
if (selectedBoardId) {
const boardExists = data.boards?.some(b => b.id === selectedBoardId)
if (!boardExists && data.boards?.length > 0) {
setSelectedBoardId(data.boards[0].id)
}
} else if (data.boards?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data.boards[0].id)
}
}
return true
}
} catch (err) {
console.error('Error loading boards from cache:', err)
}
return false
}
// Сохранение досок в кэш
const saveBoardsToCache = (boardsData) => {
try {
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({
boards: boardsData,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving boards to cache:', err)
}
}
// Загрузка желаний из кэша (по board_id)
const loadItemsFromCache = (boardId) => {
try {
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
if (cached) { if (cached) {
const data = JSON.parse(cached) const data = JSON.parse(cached)
setItems(data.items || []) setItems(data.items || [])
@@ -42,61 +115,72 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
return true return true
} }
} catch (err) { } catch (err) {
console.error('Error loading from cache:', err) console.error('Error loading items from cache:', err)
} }
return false return false
} }
// Загрузка завершённых из кэша // Сохранение желаний в кэш
const loadCompletedFromCache = () => { const saveItemsToCache = (boardId, itemsData, count) => {
try { try {
const cached = localStorage.getItem(CACHE_COMPLETED_KEY) localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
if (cached) {
const data = JSON.parse(cached)
setCompleted(data || [])
return true
}
} catch (err) {
console.error('Error loading completed from cache:', err)
}
return false
}
// Сохранение основных данных в кэш
const saveToCache = (itemsData, count) => {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
items: itemsData, items: itemsData,
completedCount: count, completedCount: count,
timestamp: Date.now() timestamp: Date.now()
})) }))
} catch (err) { } catch (err) {
console.error('Error saving to cache:', err) console.error('Error saving items to cache:', err)
} }
} }
// Сохранение завершённых в кэш // Загрузка списка досок
const saveCompletedToCache = (completedData) => { const fetchBoards = async () => {
try { try {
localStorage.setItem(CACHE_COMPLETED_KEY, JSON.stringify(completedData)) const response = await authFetch(`${API_URL}/boards`)
if (response.ok) {
const data = await response.json()
setBoards(data || [])
saveBoardsToCache(data || [])
// Проверяем, что выбранная доска существует в списке
if (selectedBoardId) {
const boardExists = data?.some(b => b.id === selectedBoardId)
if (!boardExists && data?.length > 0) {
// Сохранённая доска не существует, выбираем первую
setSelectedBoardId(data[0].id)
}
} else if (data?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data[0].id)
}
}
}
} catch (err) { } catch (err) {
console.error('Error saving completed to cache:', err) console.error('Error fetching boards:', err)
} finally {
setBoardsLoading(false)
} }
} }
// Загрузка основного списка // Загрузка желаний выбранной доски
const fetchWishlist = async () => { const fetchItems = async () => {
if (fetchingRef.current) return if (!selectedBoardId || fetchingRef.current) return
fetchingRef.current = true fetchingRef.current = true
try { try {
const hasDataInState = items.length > 0 || completedCount > 0 const hasDataInState = items.length > 0 || completedCount > 0
const cacheExists = hasCache() if (!hasDataInState) {
if (!hasDataInState && !cacheExists) { const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (!cacheLoaded) {
setLoading(true) setLoading(true)
} }
}
const response = await authFetch(API_URL) const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка при загрузке желаний') throw new Error('Ошибка при загрузке желаний')
@@ -108,11 +192,11 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
setItems(allItems) setItems(allItems)
setCompletedCount(count) setCompletedCount(count)
saveToCache(allItems, count) saveItemsToCache(selectedBoardId, allItems, count)
setError('') setError('')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
if (!hasCache()) { if (!loadItemsFromCache(selectedBoardId)) {
setItems([]) setItems([])
setCompletedCount(0) setCompletedCount(0)
} }
@@ -122,14 +206,15 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
} }
} }
// Загрузка завершённых // Загрузка завершённых для текущей доски
const fetchCompleted = async () => { const fetchCompleted = async () => {
if (fetchingCompletedRef.current) return if (fetchingCompletedRef.current || !selectedBoardId) return
fetchingCompletedRef.current = true fetchingCompletedRef.current = true
try { try {
setCompletedLoading(true) setCompletedLoading(true)
const response = await authFetch(`${API_URL}/completed`) // Используем новый API для получения завершённых на доске
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка при загрузке завершённых желаний') throw new Error('Ошибка при загрузке завершённых желаний')
@@ -138,7 +223,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const data = await response.json() const data = await response.json()
const completedData = Array.isArray(data) ? data : [] const completedData = Array.isArray(data) ? data : []
setCompleted(completedData) setCompleted(completedData)
saveCompletedToCache(completedData)
} catch (err) { } catch (err) {
console.error('Error fetching completed items:', err) console.error('Error fetching completed items:', err)
setCompleted([]) setCompleted([])
@@ -153,85 +237,173 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
if (!initialFetchDoneRef.current) { if (!initialFetchDoneRef.current) {
initialFetchDoneRef.current = true initialFetchDoneRef.current = true
// Загружаем из кэша // Загружаем доски из кэша
const cacheLoaded = loadFromCache() const boardsCacheLoaded = loadBoardsFromCache()
if (boardsCacheLoaded) {
setBoardsLoading(false)
}
// Загружаем доски с сервера
fetchBoards()
}
}, [])
// Загружаем желания при смене доски
useEffect(() => {
if (selectedBoardId) {
// Сбрасываем состояние
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Пробуем загрузить из кэша
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (cacheLoaded) { if (cacheLoaded) {
setLoading(false) setLoading(false)
} }
// Загружаем свежие данные // Загружаем свежие данные
fetchWishlist() fetchItems()
// Если список завершённых раскрыт - загружаем их тоже
if (completedExpanded) {
loadCompletedFromCache()
fetchCompleted()
} }
} }, [selectedBoardId])
}, [])
// Обработка активации/деактивации таба // Обновление при активации таба
useEffect(() => { useEffect(() => {
const wasActive = prevIsActiveRef.current const wasActive = prevIsActiveRef.current
prevIsActiveRef.current = isActive prevIsActiveRef.current = isActive
// Пропускаем первую инициализацию (она обрабатывается отдельно)
if (!initialFetchDoneRef.current) return if (!initialFetchDoneRef.current) return
// Когда таб становится видимым
if (isActive && !wasActive) { if (isActive && !wasActive) {
// Показываем кэш, если есть данные fetchBoards()
const hasDataInState = items.length > 0 || completedCount > 0 if (selectedBoardId) {
if (!hasDataInState) { fetchItems()
const cacheLoaded = loadFromCache()
if (cacheLoaded) {
setLoading(false)
} else {
setLoading(true)
}
}
// Всегда загружаем свежие данные основного списка
fetchWishlist()
// Если список завершённых раскрыт - загружаем их тоже
if (completedExpanded && completedCount > 0) {
fetchCompleted()
} }
} }
}, [isActive]) }, [isActive])
// Обновляем данные при изменении refreshTrigger // Обновление при refreshTrigger
useEffect(() => { useEffect(() => {
if (refreshTrigger > 0) { if (refreshTrigger > 0 && selectedBoardId) {
fetchWishlist() // Очищаем кэш для текущей доски, чтобы загрузить свежие данные
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
fetchBoards()
fetchItems()
if (completedExpanded && completedCount > 0) { if (completedExpanded && completedCount > 0) {
fetchCompleted() fetchCompleted()
} }
} }
}, [refreshTrigger]) }, [refreshTrigger, selectedBoardId])
// Обновление при initialBoardId (когда создана новая доска или переход по ссылке)
useEffect(() => {
if (initialBoardId && initialBoardId !== selectedBoardId) {
// Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку
fetchingRef.current = false
// Обновляем список досок (чтобы новая доска появилась)
fetchBoards().then(() => {
// Переключаемся на новую доску после обновления списка
// Это вызовет useEffect для selectedBoardId, который загрузит данные
setSelectedBoardId(initialBoardId)
})
}
}, [initialBoardId])
// Обработка удаления доски - выбираем первую доступную
useEffect(() => {
if (boardDeleted && boards.length > 0) {
// Очищаем текущие данные
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Обновляем список досок и выбираем первую
fetchBoards().then(() => {
// fetchBoards обновит boards, но мы уже в этом useEffect
// selectedBoardId обновится автоматически в useEffect ниже
})
}
}, [boardDeleted])
// Если текущая доска больше не существует в списке - выбираем первую
useEffect(() => {
if (boards.length > 0 && selectedBoardId) {
const boardExists = boards.some(b => b.id === selectedBoardId)
if (!boardExists) {
setSelectedBoardId(boards[0].id)
}
}
}, [boards, selectedBoardId])
const handleBoardChange = (boardId) => {
setSelectedBoardId(boardId)
}
const handleBoardEdit = () => {
const board = boards.find(b => b.id === selectedBoardId)
if (board?.is_owner) {
onNavigate?.('board-form', { boardId: selectedBoardId })
} else {
// Показать подтверждение выхода
handleLeaveBoard()
}
}
const handleLeaveBoard = async () => {
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
try {
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
method: 'POST'
})
if (response.ok) {
// Убираем доску из списка
const newBoards = boards.filter(b => b.id !== selectedBoardId)
setBoards(newBoards)
saveBoardsToCache(newBoards)
// Выбираем первую доску
if (newBoards.length > 0) {
setSelectedBoardId(newBoards[0].id)
} else {
setSelectedBoardId(null)
setItems([])
}
}
} catch (err) {
console.error('Error leaving board:', err)
}
}
const handleAddBoard = () => {
onNavigate?.('board-form', { boardId: null })
}
const handleToggleCompleted = () => { const handleToggleCompleted = () => {
const newExpanded = !completedExpanded const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded) setCompletedExpanded(newExpanded)
// При раскрытии загружаем завершённые
if (newExpanded && completedCount > 0) { if (newExpanded && completedCount > 0) {
// Показываем из кэша если есть
if (completed.length === 0) {
loadCompletedFromCache()
}
// Загружаем свежие данные
fetchCompleted() fetchCompleted()
} }
} }
const handleAddClick = () => { const handleAddClick = () => {
onNavigate?.('wishlist-form', { wishlistId: undefined }) onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: selectedBoardId })
} }
const handleItemClick = (item) => { const handleItemClick = (item) => {
onNavigate?.('wishlist-detail', { wishlistId: item.id }) onNavigate?.('wishlist-detail', { wishlistId: item.id, boardId: selectedBoardId })
} }
const handleMenuClick = (item, e) => { const handleMenuClick = (item, e) => {
@@ -241,7 +413,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const handleEdit = () => { const handleEdit = () => {
if (selectedItem) { if (selectedItem) {
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id }) onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
setSelectedItem(null) setSelectedItem(null)
} }
} }
@@ -259,30 +431,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
} }
setSelectedItem(null) setSelectedItem(null)
await fetchWishlist() await fetchItems()
if (completedExpanded) {
await fetchCompleted()
}
} 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()
if (completedExpanded) { if (completedExpanded) {
await fetchCompleted() await fetchCompleted()
} }
@@ -307,7 +456,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const newItem = await response.json() const newItem = await response.json()
setSelectedItem(null) setSelectedItem(null)
onNavigate?.('wishlist-form', { wishlistId: newItem.id }) onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
setSelectedItem(null) setSelectedItem(null)
@@ -430,7 +579,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
) )
} }
if (loading) { // Показываем loading только если и доски и желания грузятся
if (boardsLoading && loading) {
return ( return (
<div className="wishlist"> <div className="wishlist">
<div className="fixed inset-0 bottom-20 flex justify-center items-center"> <div className="fixed inset-0 bottom-20 flex justify-center items-center">
@@ -443,17 +593,41 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
) )
} }
if (error) { if (error && items.length === 0) {
return ( return (
<div className="wishlist"> <div className="wishlist">
<LoadingError onRetry={() => fetchWishlist()} /> <BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
/>
<LoadingError onRetry={() => fetchItems()} />
</div> </div>
) )
} }
return ( return (
<div className="wishlist"> <div className="wishlist">
{/* Основной список (разблокированные и заблокированные вместе) */} {/* Селектор доски */}
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
/>
{/* Основной список */}
{loading ? (
<div className="wishlist-loading">
<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"> <div className="wishlist-grid">
{items.map(renderItem)} {items.map(renderItem)}
<button <button
@@ -464,7 +638,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
</button> </button>
</div> </div>
{/* Завершённые - показываем только если есть завершённые желания */} {/* Завершённые */}
{completedCount > 0 && ( {completedCount > 0 && (
<> <>
<div className="section-divider"> <div className="section-divider">
@@ -493,6 +667,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
)} )}
</> </>
)} )}
</>
)}
{/* Модальное окно для действий */} {/* Модальное окно для действий */}
{selectedItem && ( {selectedItem && (
@@ -520,4 +696,3 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
} }
export default Wishlist export default Wishlist

View File

@@ -9,7 +9,7 @@ import './TaskList.css'
const API_URL = '/api/wishlist' const API_URL = '/api/wishlist'
function WishlistDetail({ wishlistId, onNavigate, onRefresh }) { function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
const { authFetch } = useAuth() const { authFetch, user } = useAuth()
const [wishlistItem, setWishlistItem] = useState(null) const [wishlistItem, setWishlistItem] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingWishlist, setLoadingWishlist] = useState(true) const [loadingWishlist, setLoadingWishlist] = useState(true)
@@ -404,7 +404,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
{/* Связанная задача или кнопки действий */} {/* Связанная задача или кнопки действий */}
{wishlistItem.unlocked && !wishlistItem.completed && ( {wishlistItem.unlocked && !wishlistItem.completed && (
<> <>
{wishlistItem.linked_task ? ( {wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
<div className="wishlist-detail-linked-task"> <div className="wishlist-detail-linked-task">
<div className="linked-task-label-header">Связанная задача:</div> <div className="linked-task-label-header">Связанная задача:</div>
<div <div

View File

@@ -9,7 +9,7 @@ const TASKS_API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects' const PROJECTS_API_URL = '/projects'
const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState' const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState'
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId }) { function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, boardId }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [price, setPrice] = useState('') const [price, setPrice] = useState('')
@@ -496,8 +496,22 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
})), })),
} }
const url = wishlistId ? `${API_URL}/${wishlistId}` : API_URL let url, method
const method = wishlistId ? 'PUT' : 'POST' 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, { const response = await authFetch(url, {
method, method,
@@ -544,7 +558,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
} }
resetForm() resetForm()
// Возвращаемся на доску, если она была указана
if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist') onNavigate?.('wishlist')
}
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -554,8 +573,13 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
const handleCancel = () => { const handleCancel = () => {
resetForm() resetForm()
// Возвращаемся на доску, если она была указана
if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist') onNavigate?.('wishlist')
} }
}
if (loadingWishlist) { if (loadingWishlist) {
return ( return (