Доски желаний и политика награждения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
116
play-life-backend/migrations/023_add_wishlist_boards.sql
Normal file
116
play-life-backend/migrations/023_add_wishlist_boards.sql
Normal 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';
|
||||||
|
|
||||||
13
play-life-backend/migrations/024_add_reward_policy.sql
Normal file
13
play-life-backend/migrations/024_add_reward_policy.sql
Normal 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';
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
170
play-life-web/src/components/BoardForm.css
Normal file
170
play-life-web/src/components/BoardForm.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
279
play-life-web/src/components/BoardForm.jsx
Normal file
279
play-life-web/src/components/BoardForm.jsx
Normal 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
|
||||||
|
|
||||||
199
play-life-web/src/components/BoardJoinPreview.css
Normal file
199
play-life-web/src/components/BoardJoinPreview.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
156
play-life-web/src/components/BoardJoinPreview.jsx
Normal file
156
play-life-web/src/components/BoardJoinPreview.jsx
Normal 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
|
||||||
|
|
||||||
132
play-life-web/src/components/BoardMembers.css
Normal file
132
play-life-web/src/components/BoardMembers.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
113
play-life-web/src/components/BoardMembers.jsx
Normal file
113
play-life-web/src/components/BoardMembers.jsx
Normal 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
|
||||||
|
|
||||||
242
play-life-web/src/components/BoardSelector.css
Normal file
242
play-life-web/src/components/BoardSelector.css
Normal 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;
|
||||||
|
}
|
||||||
132
play-life-web/src/components/BoardSelector.jsx
Normal file
132
play-life-web/src/components/BoardSelector.jsx
Normal 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
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
setLoading(true)
|
if (!cacheLoaded) {
|
||||||
|
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,51 +593,77 @@ 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">
|
||||||
{/* Основной список (разблокированные и заблокированные вместе) */}
|
{/* Селектор доски */}
|
||||||
<div className="wishlist-grid">
|
<BoardSelector
|
||||||
{items.map(renderItem)}
|
boards={boards}
|
||||||
<button
|
selectedBoardId={selectedBoardId}
|
||||||
onClick={handleAddClick}
|
onBoardChange={handleBoardChange}
|
||||||
className="add-wishlist-button"
|
onBoardEdit={handleBoardEdit}
|
||||||
>
|
onAddBoard={handleAddBoard}
|
||||||
<div className="add-wishlist-icon">+</div>
|
loading={boardsLoading}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Завершённые - показываем только если есть завершённые желания */}
|
{/* Основной список */}
|
||||||
{completedCount > 0 && (
|
{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="section-divider">
|
<div className="wishlist-grid">
|
||||||
|
{items.map(renderItem)}
|
||||||
<button
|
<button
|
||||||
className="completed-toggle"
|
onClick={handleAddClick}
|
||||||
onClick={handleToggleCompleted}
|
className="add-wishlist-button"
|
||||||
>
|
>
|
||||||
<span className="completed-toggle-icon">
|
<div className="add-wishlist-icon">+</div>
|
||||||
{completedExpanded ? '▼' : '▶'}
|
|
||||||
</span>
|
|
||||||
<span>Завершённые</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{completedExpanded && (
|
|
||||||
|
{/* Завершённые */}
|
||||||
|
{completedCount > 0 && (
|
||||||
<>
|
<>
|
||||||
{completedLoading ? (
|
<div className="section-divider">
|
||||||
<div className="loading-completed">
|
<button
|
||||||
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
className="completed-toggle"
|
||||||
</div>
|
onClick={handleToggleCompleted}
|
||||||
) : (
|
>
|
||||||
<div className="wishlist-grid">
|
<span className="completed-toggle-icon">
|
||||||
{completed.map(renderItem)}
|
{completedExpanded ? '▼' : '▶'}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -520,4 +696,3 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default Wishlist
|
export default Wishlist
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
onNavigate?.('wishlist')
|
// Возвращаемся на доску, если она была указана
|
||||||
|
if (boardId) {
|
||||||
|
onNavigate?.('wishlist', { boardId })
|
||||||
|
} else {
|
||||||
|
onNavigate?.('wishlist')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -554,7 +573,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
resetForm()
|
resetForm()
|
||||||
onNavigate?.('wishlist')
|
// Возвращаемся на доску, если она была указана
|
||||||
|
if (boardId) {
|
||||||
|
onNavigate?.('wishlist', { boardId })
|
||||||
|
} else {
|
||||||
|
onNavigate?.('wishlist')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingWishlist) {
|
if (loadingWishlist) {
|
||||||
|
|||||||
Reference in New Issue
Block a user