6.25.0: Меню действий для досок вместо кнопок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "6.24.0",
|
"version": "6.25.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import BoardMembers from './BoardMembers'
|
import BoardMembers from './BoardMembers'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
import DeleteButton from './DeleteButton'
|
|
||||||
import './Buttons.css'
|
import './Buttons.css'
|
||||||
import './BoardForm.css'
|
import './BoardForm.css'
|
||||||
|
import './Wishlist.css'
|
||||||
|
|
||||||
function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
@@ -19,6 +19,9 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [isOwner, setIsOwner] = useState(true)
|
const [isOwner, setIsOwner] = useState(true)
|
||||||
const [isArchived, setIsArchived] = useState(false)
|
const [isArchived, setIsArchived] = useState(false)
|
||||||
|
const [showActionMenu, setShowActionMenu] = useState(false)
|
||||||
|
const actionMenuHistoryRef = useRef(false)
|
||||||
|
const savedHistoryStateRef = useRef(null)
|
||||||
|
|
||||||
const isEdit = !!boardId
|
const isEdit = !!boardId
|
||||||
|
|
||||||
@@ -137,6 +140,17 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Навигация после действия из action menu: убрать обе записи (action menu + board-form)
|
||||||
|
const navigateBackFromActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.go(-2)
|
||||||
|
} else {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
|
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
|
||||||
|
|
||||||
@@ -147,8 +161,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
|
navigateBackFromActionMenu()
|
||||||
onNavigate('wishlist', { boardDeleted: true })
|
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -168,7 +181,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
onNavigate('wishlist', { boardDeleted: true })
|
onNavigate('wishlist', { boardDeleted: true }, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -186,7 +199,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
onNavigate('wishlist', { boardDeleted: true })
|
navigateBackFromActionMenu()
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -201,9 +214,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsArchived(false)
|
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
|
navigateBackFromActionMenu()
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -212,6 +224,42 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openActionMenu = () => {
|
||||||
|
setShowActionMenu(true)
|
||||||
|
savedHistoryStateRef.current = window.history.state
|
||||||
|
window.history.pushState({ actionMenu: true }, '')
|
||||||
|
actionMenuHistoryRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть меню без popstate — заменяем запись в истории на сохранённое состояние
|
||||||
|
const dismissActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.replaceState(savedHistoryStateRef.current, '', window.location.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка popstate для закрытия action menu кнопкой назад
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (showActionMenu) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
setShowActionMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState)
|
||||||
|
}, [showActionMenu])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
}
|
}
|
||||||
@@ -229,58 +277,6 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для не-владельца показываем упрощённую форму
|
|
||||||
if (isEdit && !isOwner) {
|
|
||||||
return (
|
|
||||||
<div className="board-form">
|
|
||||||
<button className="close-x-button" onClick={handleClose}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2>{name}</h2>
|
|
||||||
|
|
||||||
<div className="form-card">
|
|
||||||
<div className="board-actions-list">
|
|
||||||
{isArchived ? (
|
|
||||||
<button className="board-action-button" onClick={handleUnarchive}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Разархивировать</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="board-action-button" onClick={handleArchive}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
|
||||||
<rect x="1" y="3" width="22" height="5"></rect>
|
|
||||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
<span>Архивировать</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="board-action-button board-action-danger" onClick={handleLeave}>
|
|
||||||
<svg width="20" height="20" 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>
|
|
||||||
<span>Покинуть доску</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toastMessage && (
|
|
||||||
<Toast
|
|
||||||
message={toastMessage.text}
|
|
||||||
type={toastMessage.type}
|
|
||||||
onClose={() => setToastMessage(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="board-form">
|
<div className="board-form">
|
||||||
<button className="close-x-button" onClick={handleClose}>
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
@@ -359,27 +355,6 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Архивирование */}
|
|
||||||
<div className="form-section">
|
|
||||||
{isArchived ? (
|
|
||||||
<button className="board-action-button" onClick={handleUnarchive} style={{ marginTop: '0.5rem' }}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Разархивировать</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="board-action-button" onClick={handleArchive} style={{ marginTop: '0.5rem' }}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
|
||||||
<rect x="1" y="3" width="22" height="5"></rect>
|
|
||||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
<span>Архивировать</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -429,16 +404,61 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<DeleteButton
|
<button
|
||||||
onClick={handleDelete}
|
type="button"
|
||||||
loading={isDeleting}
|
onClick={openActionMenu}
|
||||||
disabled={loading}
|
disabled={loading || isDeleting}
|
||||||
title="Удалить доску"
|
style={{
|
||||||
/>
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#059669',
|
||||||
|
border: '2px solid #059669',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: (loading || isDeleting) ? 'not-allowed' : 'pointer',
|
||||||
|
lineHeight: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
title="Действия"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
) : null}
|
) : null}
|
||||||
|
{showActionMenu && createPortal(
|
||||||
|
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
|
||||||
|
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="wishlist-modal-header">
|
||||||
|
<h3>{name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wishlist-modal-actions">
|
||||||
|
{isArchived ? (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleUnarchive}>
|
||||||
|
Разархивировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleArchive}>
|
||||||
|
Архивировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import BoardMembers from './BoardMembers'
|
import BoardMembers from './BoardMembers'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
import DeleteButton from './DeleteButton'
|
|
||||||
import './Buttons.css'
|
import './Buttons.css'
|
||||||
import './BoardForm.css'
|
import './BoardForm.css'
|
||||||
|
import './Wishlist.css'
|
||||||
|
|
||||||
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
@@ -19,6 +19,9 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [isOwner, setIsOwner] = useState(true)
|
const [isOwner, setIsOwner] = useState(true)
|
||||||
const [isArchived, setIsArchived] = useState(false)
|
const [isArchived, setIsArchived] = useState(false)
|
||||||
|
const [showActionMenu, setShowActionMenu] = useState(false)
|
||||||
|
const actionMenuHistoryRef = useRef(false)
|
||||||
|
const savedHistoryStateRef = useRef(null)
|
||||||
|
|
||||||
const isEdit = !!boardId
|
const isEdit = !!boardId
|
||||||
|
|
||||||
@@ -132,6 +135,17 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Навигация после действия из action menu: убрать обе записи (action menu + board-form)
|
||||||
|
const navigateBackFromActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.go(-2)
|
||||||
|
} else {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
|
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
|
||||||
|
|
||||||
@@ -142,7 +156,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
onNavigate('shopping', { boardDeleted: true })
|
navigateBackFromActionMenu()
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -162,7 +176,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
onNavigate('shopping', { boardDeleted: true })
|
onNavigate('shopping', { boardDeleted: true }, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -180,7 +194,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
onNavigate('shopping', { boardDeleted: true })
|
navigateBackFromActionMenu()
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -195,9 +209,8 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsArchived(false)
|
|
||||||
onSaved?.()
|
onSaved?.()
|
||||||
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
|
navigateBackFromActionMenu()
|
||||||
} else {
|
} else {
|
||||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -206,6 +219,42 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openActionMenu = () => {
|
||||||
|
setShowActionMenu(true)
|
||||||
|
savedHistoryStateRef.current = window.history.state
|
||||||
|
window.history.pushState({ actionMenu: true }, '')
|
||||||
|
actionMenuHistoryRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть меню без popstate — заменяем запись в истории на сохранённое состояние
|
||||||
|
const dismissActionMenu = () => {
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.replaceState(savedHistoryStateRef.current, '', window.location.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка popstate для закрытия action menu кнопкой назад
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (showActionMenu) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
setShowActionMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState)
|
||||||
|
}, [showActionMenu])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
}
|
}
|
||||||
@@ -223,58 +272,6 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для не-владельца показываем упрощённую форму
|
|
||||||
if (isEdit && !isOwner) {
|
|
||||||
return (
|
|
||||||
<div className="board-form">
|
|
||||||
<button className="close-x-button" onClick={handleClose}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2>{name}</h2>
|
|
||||||
|
|
||||||
<div className="form-card">
|
|
||||||
<div className="board-actions-list">
|
|
||||||
{isArchived ? (
|
|
||||||
<button className="board-action-button" onClick={handleUnarchive}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Разархивировать</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="board-action-button" onClick={handleArchive}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
|
||||||
<rect x="1" y="3" width="22" height="5"></rect>
|
|
||||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
<span>Архивировать</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="board-action-button board-action-danger" onClick={handleLeave}>
|
|
||||||
<svg width="20" height="20" 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>
|
|
||||||
<span>Покинуть доску</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toastMessage && (
|
|
||||||
<Toast
|
|
||||||
message={toastMessage.text}
|
|
||||||
type={toastMessage.type}
|
|
||||||
onClose={() => setToastMessage(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="board-form">
|
<div className="board-form">
|
||||||
<button className="close-x-button" onClick={handleClose}>
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
@@ -352,27 +349,6 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Архивирование */}
|
|
||||||
<div className="form-section">
|
|
||||||
{isArchived ? (
|
|
||||||
<button className="board-action-button" onClick={handleUnarchive} style={{ marginTop: '0.5rem' }}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="1 4 1 10 7 10"></polyline>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Разархивировать</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="board-action-button" onClick={handleArchive} style={{ marginTop: '0.5rem' }}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
|
||||||
<rect x="1" y="3" width="22" height="5"></rect>
|
|
||||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
<span>Архивировать</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -422,16 +398,61 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
|||||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<DeleteButton
|
<button
|
||||||
onClick={handleDelete}
|
type="button"
|
||||||
loading={isDeleting}
|
onClick={openActionMenu}
|
||||||
disabled={loading}
|
disabled={loading || isDeleting}
|
||||||
title="Удалить доску"
|
style={{
|
||||||
/>
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#059669',
|
||||||
|
border: '2px solid #059669',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: (loading || isDeleting) ? 'not-allowed' : 'pointer',
|
||||||
|
lineHeight: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
title="Действия"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
) : null}
|
) : null}
|
||||||
|
{showActionMenu && createPortal(
|
||||||
|
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
|
||||||
|
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="wishlist-modal-header">
|
||||||
|
<h3>{name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wishlist-modal-actions">
|
||||||
|
{isArchived ? (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleUnarchive}>
|
||||||
|
Разархивировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleArchive}>
|
||||||
|
Архивировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'react-day-picker/style.css'
|
|||||||
import './TaskList.css'
|
import './TaskList.css'
|
||||||
import './TaskDetail.css'
|
import './TaskDetail.css'
|
||||||
import './ShoppingList.css'
|
import './ShoppingList.css'
|
||||||
|
import './Wishlist.css'
|
||||||
|
|
||||||
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
|
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
|
||||||
const ITEMS_CACHE_KEY = 'shopping_items_cache'
|
const ITEMS_CACHE_KEY = 'shopping_items_cache'
|
||||||
@@ -133,6 +134,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
|||||||
const [postponeRemaining, setPostponeRemaining] = useState('')
|
const [postponeRemaining, setPostponeRemaining] = useState('')
|
||||||
const [isPostponing, setIsPostponing] = useState(false)
|
const [isPostponing, setIsPostponing] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [showBoardActionMenu, setShowBoardActionMenu] = useState(false)
|
||||||
const initialFetchDoneRef = useRef(false)
|
const initialFetchDoneRef = useRef(false)
|
||||||
const prevIsActiveRef = useRef(isActive)
|
const prevIsActiveRef = useRef(isActive)
|
||||||
const itemsAbortRef = useRef(null)
|
const itemsAbortRef = useRef(null)
|
||||||
@@ -388,15 +390,77 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
|||||||
|
|
||||||
const handleBoardEdit = (boardId) => {
|
const handleBoardEdit = (boardId) => {
|
||||||
const id = boardId || selectedBoardId
|
const id = boardId || selectedBoardId
|
||||||
if (id) {
|
if (!id) return
|
||||||
onNavigate('shopping-board-form', { boardId: id })
|
const board = boards.find(b => b.id === id)
|
||||||
|
if (board && !board.is_owner) {
|
||||||
|
openBoardActionMenu()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
onNavigate('shopping-board-form', { boardId: id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddBoard = () => {
|
const handleAddBoard = () => {
|
||||||
onNavigate('shopping-board-form')
|
onNavigate('shopping-board-form')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openBoardActionMenu = () => {
|
||||||
|
setShowBoardActionMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBoardActionMenu = () => {
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardArchive = async () => {
|
||||||
|
const board = boards.find(b => b.id === selectedBoardId)
|
||||||
|
if (!board) return
|
||||||
|
|
||||||
|
if (board.is_archived) {
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/unarchive`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
fetchItems(selectedBoardId)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error unarchiving board:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
|
||||||
|
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/archive`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error archiving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardLeave = async () => {
|
||||||
|
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
|
||||||
|
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error leaving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
if (selectedBoardId) fetchItems(selectedBoardId)
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
||||||
}
|
}
|
||||||
@@ -941,6 +1005,31 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
|||||||
onClose={() => setToast(null)}
|
onClose={() => setToast(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBoardActionMenu && createPortal(
|
||||||
|
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeBoardActionMenu}>
|
||||||
|
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="wishlist-modal-header">
|
||||||
|
<h3>{boards.find(b => b.id === selectedBoardId)?.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wishlist-modal-actions">
|
||||||
|
{boards.find(b => b.id === selectedBoardId)?.is_archived ? (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
|
||||||
|
Разархивировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
|
||||||
|
Архивировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="wishlist-modal-delete" onClick={handleBoardLeave}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import BoardSelector from './BoardSelector'
|
import BoardSelector from './BoardSelector'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
@@ -47,6 +48,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
const [selectedItem, setSelectedItem] = useState(null)
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
|
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
|
||||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
|
const [showBoardActionMenu, setShowBoardActionMenu] = useState(false)
|
||||||
const fetchingRef = useRef(false)
|
const fetchingRef = useRef(false)
|
||||||
const fetchingCompletedRef = useRef(false)
|
const fetchingCompletedRef = useRef(false)
|
||||||
const initialFetchDoneRef = useRef(false)
|
const initialFetchDoneRef = useRef(false)
|
||||||
@@ -393,13 +395,77 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleBoardEdit = (boardId) => {
|
const handleBoardEdit = (boardId) => {
|
||||||
onNavigate?.('board-form', { boardId: boardId || selectedBoardId })
|
const id = boardId || selectedBoardId
|
||||||
|
const board = boards.find(b => b.id === id)
|
||||||
|
if (board && !board.is_owner) {
|
||||||
|
openBoardActionMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onNavigate?.('board-form', { boardId: id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddBoard = () => {
|
const handleAddBoard = () => {
|
||||||
onNavigate?.('board-form', { boardId: null })
|
onNavigate?.('board-form', { boardId: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openBoardActionMenu = () => {
|
||||||
|
setShowBoardActionMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBoardActionMenu = () => {
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardArchive = async () => {
|
||||||
|
const board = boards.find(b => b.id === selectedBoardId)
|
||||||
|
if (!board) return
|
||||||
|
|
||||||
|
if (board.is_archived) {
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${selectedBoardId}/unarchive`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
fetchItems(selectedBoardId)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error unarchiving board:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
|
||||||
|
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${selectedBoardId}/archive`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error archiving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardLeave = async () => {
|
||||||
|
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её желания.')) return
|
||||||
|
|
||||||
|
setShowBoardActionMenu(false)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${selectedBoardId}/leave`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error leaving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleCompleted = () => {
|
const handleToggleCompleted = () => {
|
||||||
const newExpanded = !completedExpanded
|
const newExpanded = !completedExpanded
|
||||||
setCompletedExpanded(newExpanded)
|
setCompletedExpanded(newExpanded)
|
||||||
@@ -789,6 +855,31 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
onClose={handleCloseDetail}
|
onClose={handleCloseDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBoardActionMenu && createPortal(
|
||||||
|
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeBoardActionMenu}>
|
||||||
|
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="wishlist-modal-header">
|
||||||
|
<h3>{boards.find(b => b.id === selectedBoardId)?.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wishlist-modal-actions">
|
||||||
|
{boards.find(b => b.id === selectedBoardId)?.is_archived ? (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
|
||||||
|
Разархивировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
|
||||||
|
Архивировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="wishlist-modal-delete" onClick={handleBoardLeave}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user