Доски желаний и политика награждения
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.12.2",
|
||||
"version": "3.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -12,6 +12,8 @@ import TaskForm from './components/TaskForm.jsx'
|
||||
import Wishlist from './components/Wishlist'
|
||||
import WishlistForm from './components/WishlistForm'
|
||||
import WishlistDetail from './components/WishlistDetail'
|
||||
import BoardForm from './components/BoardForm'
|
||||
import BoardJoinPreview from './components/BoardJoinPreview'
|
||||
import TodoistIntegration from './components/TodoistIntegration'
|
||||
import TelegramIntegration from './components/TelegramIntegration'
|
||||
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 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() {
|
||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||
@@ -57,6 +59,8 @@ function AppContent() {
|
||||
wishlist: false,
|
||||
'wishlist-form': false,
|
||||
'wishlist-detail': false,
|
||||
'board-form': false,
|
||||
'board-join': false,
|
||||
profile: false,
|
||||
'todoist-integration': false,
|
||||
'telegram-integration': false,
|
||||
@@ -76,6 +80,8 @@ function AppContent() {
|
||||
wishlist: false,
|
||||
'wishlist-form': false,
|
||||
'wishlist-detail': false,
|
||||
'board-form': false,
|
||||
'board-join': false,
|
||||
profile: false,
|
||||
'todoist-integration': false,
|
||||
'telegram-integration': false,
|
||||
@@ -122,10 +128,25 @@ function AppContent() {
|
||||
if (isInitialized) return
|
||||
|
||||
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 только для глубоких табов
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
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)) {
|
||||
// Если в URL есть глубокий таб, восстанавливаем его
|
||||
@@ -616,7 +637,7 @@ function AppContent() {
|
||||
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
|
||||
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) {
|
||||
setTabParams({})
|
||||
if (isNewTabMain) {
|
||||
@@ -655,7 +676,12 @@ function AppContent() {
|
||||
}
|
||||
// Обновляем список желаний при возврате из экрана редактирования
|
||||
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)
|
||||
}
|
||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||
@@ -859,6 +885,8 @@ function AppContent() {
|
||||
onNavigate={handleNavigate}
|
||||
refreshTrigger={wishlistRefreshTrigger}
|
||||
isActive={activeTab === 'wishlist'}
|
||||
initialBoardId={tabParams.boardId}
|
||||
boardDeleted={tabParams.boardDeleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -866,11 +894,12 @@ function AppContent() {
|
||||
{loadedTabs['wishlist-form'] && (
|
||||
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
||||
<WishlistForm
|
||||
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}`}
|
||||
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}-${tabParams.boardId ?? ''}`}
|
||||
onNavigate={handleNavigate}
|
||||
wishlistId={tabParams.wishlistId}
|
||||
editConditionIndex={tabParams.editConditionIndex}
|
||||
newTaskId={tabParams.newTaskId}
|
||||
boardId={tabParams.boardId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -886,6 +915,27 @@ function AppContent() {
|
||||
</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 && (
|
||||
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||
<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 [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
||||
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
|
||||
const [rewardPolicy, setRewardPolicy] = useState('personal') // Политика награждения: 'personal' или 'general'
|
||||
// Test-specific state
|
||||
const [isTest, setIsTest] = useState(isTestFromProps)
|
||||
const [wordsCount, setWordsCount] = useState('10')
|
||||
@@ -122,6 +123,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
|
||||
} else {
|
||||
setCurrentWishlistId(null)
|
||||
setWishlistInfo(null)
|
||||
setRewardPolicy('personal') // Сбрасываем при отвязке
|
||||
}
|
||||
}
|
||||
}, [taskId, wishlistId, authFetch])
|
||||
@@ -339,9 +341,16 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
|
||||
} catch (err) {
|
||||
console.error('Error loading wishlist info:', err)
|
||||
}
|
||||
// Загружаем политику награждения
|
||||
if (data.task.reward_policy) {
|
||||
setRewardPolicy(data.task.reward_policy)
|
||||
} else {
|
||||
setRewardPolicy('personal') // Значение по умолчанию
|
||||
}
|
||||
} else {
|
||||
setCurrentWishlistId(null)
|
||||
setWishlistInfo(null)
|
||||
setRewardPolicy('personal') // Сбрасываем при отвязке
|
||||
}
|
||||
|
||||
// Загружаем информацию о тесте, если есть config_id
|
||||
@@ -628,6 +637,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
|
||||
wishlist_id: taskId
|
||||
? (currentWishlistId && !wishlistInfo ? null : undefined)
|
||||
: (currentWishlistId || undefined),
|
||||
reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined,
|
||||
rewards: rewards.map(r => ({
|
||||
position: r.position,
|
||||
project_name: r.project_name.trim(),
|
||||
@@ -798,6 +808,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.wishlist-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.add-wishlist-button {
|
||||
background: transparent;
|
||||
border: 2px dashed #6b8dd6;
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import BoardSelector from './BoardSelector'
|
||||
import LoadingError from './LoadingError'
|
||||
import './Wishlist.css'
|
||||
|
||||
const API_URL = '/api/wishlist'
|
||||
const CACHE_KEY = 'wishlist_cache'
|
||||
const CACHE_COMPLETED_KEY = 'wishlist_completed_cache'
|
||||
const BOARDS_CACHE_KEY = 'wishlist_boards_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 [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 [completed, setCompleted] = useState([])
|
||||
const [completedCount, setCompletedCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [boardsLoading, setBoardsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||
const [completedLoading, setCompletedLoading] = useState(false)
|
||||
@@ -22,19 +48,66 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
const initialFetchDoneRef = useRef(false)
|
||||
const prevIsActiveRef = useRef(isActive)
|
||||
|
||||
// Проверка наличия кэша
|
||||
const hasCache = () => {
|
||||
// Обёртка для setSelectedBoardId с сохранением в localStorage
|
||||
const setSelectedBoardId = (boardId) => {
|
||||
setSelectedBoardIdState(boardId)
|
||||
try {
|
||||
return localStorage.getItem(CACHE_KEY) !== null
|
||||
if (boardId) {
|
||||
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
|
||||
} else {
|
||||
localStorage.removeItem(SELECTED_BOARD_KEY)
|
||||
}
|
||||
} catch (err) {
|
||||
return false
|
||||
console.error('Error saving selected board to cache:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка основных данных из кэша
|
||||
const loadFromCache = () => {
|
||||
// Загрузка досок из кэша
|
||||
const loadBoardsFromCache = () => {
|
||||
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) {
|
||||
const data = JSON.parse(cached)
|
||||
setItems(data.items || [])
|
||||
@@ -42,61 +115,72 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading from cache:', err)
|
||||
console.error('Error loading items from cache:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Загрузка завершённых из кэша
|
||||
const loadCompletedFromCache = () => {
|
||||
// Сохранение желаний в кэш
|
||||
const saveItemsToCache = (boardId, itemsData, count) => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_COMPLETED_KEY)
|
||||
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({
|
||||
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
|
||||
items: itemsData,
|
||||
completedCount: count,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error saving to cache:', err)
|
||||
console.error('Error saving items to cache:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранение завершённых в кэш
|
||||
const saveCompletedToCache = (completedData) => {
|
||||
// Загрузка списка досок
|
||||
const fetchBoards = async () => {
|
||||
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) {
|
||||
console.error('Error saving completed to cache:', err)
|
||||
console.error('Error fetching boards:', err)
|
||||
} finally {
|
||||
setBoardsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка основного списка
|
||||
const fetchWishlist = async () => {
|
||||
if (fetchingRef.current) return
|
||||
// Загрузка желаний выбранной доски
|
||||
const fetchItems = async () => {
|
||||
if (!selectedBoardId || fetchingRef.current) return
|
||||
fetchingRef.current = true
|
||||
|
||||
try {
|
||||
const hasDataInState = items.length > 0 || completedCount > 0
|
||||
const cacheExists = hasCache()
|
||||
if (!hasDataInState && !cacheExists) {
|
||||
setLoading(true)
|
||||
if (!hasDataInState) {
|
||||
const cacheLoaded = loadItemsFromCache(selectedBoardId)
|
||||
if (!cacheLoaded) {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await authFetch(API_URL)
|
||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке желаний')
|
||||
@@ -108,11 +192,11 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
|
||||
setItems(allItems)
|
||||
setCompletedCount(count)
|
||||
saveToCache(allItems, count)
|
||||
saveItemsToCache(selectedBoardId, allItems, count)
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
if (!hasCache()) {
|
||||
if (!loadItemsFromCache(selectedBoardId)) {
|
||||
setItems([])
|
||||
setCompletedCount(0)
|
||||
}
|
||||
@@ -122,14 +206,15 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка завершённых
|
||||
// Загрузка завершённых для текущей доски
|
||||
const fetchCompleted = async () => {
|
||||
if (fetchingCompletedRef.current) return
|
||||
if (fetchingCompletedRef.current || !selectedBoardId) return
|
||||
fetchingCompletedRef.current = true
|
||||
|
||||
try {
|
||||
setCompletedLoading(true)
|
||||
const response = await authFetch(`${API_URL}/completed`)
|
||||
// Используем новый API для получения завершённых на доске
|
||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке завершённых желаний')
|
||||
@@ -138,7 +223,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
const data = await response.json()
|
||||
const completedData = Array.isArray(data) ? data : []
|
||||
setCompleted(completedData)
|
||||
saveCompletedToCache(completedData)
|
||||
} catch (err) {
|
||||
console.error('Error fetching completed items:', err)
|
||||
setCompleted([])
|
||||
@@ -153,85 +237,173 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
if (!initialFetchDoneRef.current) {
|
||||
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) {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Загружаем свежие данные
|
||||
fetchWishlist()
|
||||
|
||||
// Если список завершённых раскрыт - загружаем их тоже
|
||||
if (completedExpanded) {
|
||||
loadCompletedFromCache()
|
||||
fetchCompleted()
|
||||
}
|
||||
fetchItems()
|
||||
}
|
||||
}, [])
|
||||
}, [selectedBoardId])
|
||||
|
||||
// Обработка активации/деактивации таба
|
||||
// Обновление при активации таба
|
||||
useEffect(() => {
|
||||
const wasActive = prevIsActiveRef.current
|
||||
prevIsActiveRef.current = isActive
|
||||
|
||||
// Пропускаем первую инициализацию (она обрабатывается отдельно)
|
||||
if (!initialFetchDoneRef.current) return
|
||||
|
||||
// Когда таб становится видимым
|
||||
if (isActive && !wasActive) {
|
||||
// Показываем кэш, если есть данные
|
||||
const hasDataInState = items.length > 0 || completedCount > 0
|
||||
if (!hasDataInState) {
|
||||
const cacheLoaded = loadFromCache()
|
||||
if (cacheLoaded) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Всегда загружаем свежие данные основного списка
|
||||
fetchWishlist()
|
||||
|
||||
// Если список завершённых раскрыт - загружаем их тоже
|
||||
if (completedExpanded && completedCount > 0) {
|
||||
fetchCompleted()
|
||||
fetchBoards()
|
||||
if (selectedBoardId) {
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
// Обновляем данные при изменении refreshTrigger
|
||||
// Обновление при refreshTrigger
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWishlist()
|
||||
if (refreshTrigger > 0 && selectedBoardId) {
|
||||
// Очищаем кэш для текущей доски, чтобы загрузить свежие данные
|
||||
try {
|
||||
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
|
||||
} catch (err) {
|
||||
console.error('Error clearing cache:', err)
|
||||
}
|
||||
fetchBoards()
|
||||
fetchItems()
|
||||
if (completedExpanded && completedCount > 0) {
|
||||
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 newExpanded = !completedExpanded
|
||||
setCompletedExpanded(newExpanded)
|
||||
|
||||
// При раскрытии загружаем завершённые
|
||||
if (newExpanded && completedCount > 0) {
|
||||
// Показываем из кэша если есть
|
||||
if (completed.length === 0) {
|
||||
loadCompletedFromCache()
|
||||
}
|
||||
// Загружаем свежие данные
|
||||
fetchCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddClick = () => {
|
||||
onNavigate?.('wishlist-form', { wishlistId: undefined })
|
||||
onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: selectedBoardId })
|
||||
}
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
onNavigate?.('wishlist-detail', { wishlistId: item.id })
|
||||
onNavigate?.('wishlist-detail', { wishlistId: item.id, boardId: selectedBoardId })
|
||||
}
|
||||
|
||||
const handleMenuClick = (item, e) => {
|
||||
@@ -241,7 +413,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
|
||||
const handleEdit = () => {
|
||||
if (selectedItem) {
|
||||
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id })
|
||||
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
@@ -259,30 +431,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
}
|
||||
|
||||
setSelectedItem(null)
|
||||
await fetchWishlist()
|
||||
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()
|
||||
await fetchItems()
|
||||
if (completedExpanded) {
|
||||
await fetchCompleted()
|
||||
}
|
||||
@@ -307,7 +456,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
const newItem = await response.json()
|
||||
|
||||
setSelectedItem(null)
|
||||
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
|
||||
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSelectedItem(null)
|
||||
@@ -430,7 +579,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Показываем loading только если и доски и желания грузятся
|
||||
if (boardsLoading && loading) {
|
||||
return (
|
||||
<div className="wishlist">
|
||||
<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 (
|
||||
<div className="wishlist">
|
||||
<LoadingError onRetry={() => fetchWishlist()} />
|
||||
<BoardSelector
|
||||
boards={boards}
|
||||
selectedBoardId={selectedBoardId}
|
||||
onBoardChange={handleBoardChange}
|
||||
onBoardEdit={handleBoardEdit}
|
||||
onAddBoard={handleAddBoard}
|
||||
loading={boardsLoading}
|
||||
/>
|
||||
<LoadingError onRetry={() => fetchItems()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wishlist">
|
||||
{/* Основной список (разблокированные и заблокированные вместе) */}
|
||||
<div className="wishlist-grid">
|
||||
{items.map(renderItem)}
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
className="add-wishlist-button"
|
||||
>
|
||||
<div className="add-wishlist-icon">+</div>
|
||||
</button>
|
||||
</div>
|
||||
{/* Селектор доски */}
|
||||
<BoardSelector
|
||||
boards={boards}
|
||||
selectedBoardId={selectedBoardId}
|
||||
onBoardChange={handleBoardChange}
|
||||
onBoardEdit={handleBoardEdit}
|
||||
onAddBoard={handleAddBoard}
|
||||
loading={boardsLoading}
|
||||
/>
|
||||
|
||||
{/* Завершённые - показываем только если есть завершённые желания */}
|
||||
{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">
|
||||
<button
|
||||
className="completed-toggle"
|
||||
onClick={handleToggleCompleted}
|
||||
<div className="wishlist-grid">
|
||||
{items.map(renderItem)}
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
className="add-wishlist-button"
|
||||
>
|
||||
<span className="completed-toggle-icon">
|
||||
{completedExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span>Завершённые</span>
|
||||
<div className="add-wishlist-icon">+</div>
|
||||
</button>
|
||||
</div>
|
||||
{completedExpanded && (
|
||||
|
||||
{/* Завершённые */}
|
||||
{completedCount > 0 && (
|
||||
<>
|
||||
{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>
|
||||
<div className="section-divider">
|
||||
<button
|
||||
className="completed-toggle"
|
||||
onClick={handleToggleCompleted}
|
||||
>
|
||||
<span className="completed-toggle-icon">
|
||||
{completedExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span>Завершённые</span>
|
||||
</button>
|
||||
</div>
|
||||
{completedExpanded && (
|
||||
<>
|
||||
{completedLoading ? (
|
||||
<div className="loading-completed">
|
||||
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wishlist-grid">
|
||||
{completed.map(renderItem)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -520,4 +696,3 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
|
||||
}
|
||||
|
||||
export default Wishlist
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import './TaskList.css'
|
||||
const API_URL = '/api/wishlist'
|
||||
|
||||
function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
||||
const { authFetch } = useAuth()
|
||||
const { authFetch, user } = useAuth()
|
||||
const [wishlistItem, setWishlistItem] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingWishlist, setLoadingWishlist] = useState(true)
|
||||
@@ -404,7 +404,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
||||
{/* Связанная задача или кнопки действий */}
|
||||
{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="linked-task-label-header">Связанная задача:</div>
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,7 @@ const TASKS_API_URL = '/api/tasks'
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState'
|
||||
|
||||
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId }) {
|
||||
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, boardId }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
@@ -496,8 +496,22 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
|
||||
})),
|
||||
}
|
||||
|
||||
const url = wishlistId ? `${API_URL}/${wishlistId}` : API_URL
|
||||
const method = wishlistId ? 'PUT' : 'POST'
|
||||
let url, method
|
||||
if (wishlistId) {
|
||||
// Редактирование существующего желания
|
||||
url = `${API_URL}/${wishlistId}`
|
||||
method = 'PUT'
|
||||
} else {
|
||||
// Создание нового желания
|
||||
if (boardId) {
|
||||
// Создание на доске
|
||||
url = `/api/wishlist/boards/${boardId}/items`
|
||||
} else {
|
||||
// Старый API для обратной совместимости
|
||||
url = API_URL
|
||||
}
|
||||
method = 'POST'
|
||||
}
|
||||
|
||||
const response = await authFetch(url, {
|
||||
method,
|
||||
@@ -544,7 +558,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
|
||||
}
|
||||
|
||||
resetForm()
|
||||
onNavigate?.('wishlist')
|
||||
// Возвращаемся на доску, если она была указана
|
||||
if (boardId) {
|
||||
onNavigate?.('wishlist', { boardId })
|
||||
} else {
|
||||
onNavigate?.('wishlist')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -554,7 +573,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId })
|
||||
|
||||
const handleCancel = () => {
|
||||
resetForm()
|
||||
onNavigate?.('wishlist')
|
||||
// Возвращаемся на доску, если она была указана
|
||||
if (boardId) {
|
||||
onNavigate?.('wishlist', { boardId })
|
||||
} else {
|
||||
onNavigate?.('wishlist')
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingWishlist) {
|
||||
|
||||
Reference in New Issue
Block a user