diff --git a/VERSION b/VERSION index 2496b04..961b1c8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.24.0 +6.25.0 diff --git a/play-life-web/package.json b/play-life-web/package.json index 9f26292..abca42c 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.24.0", + "version": "6.25.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/BoardForm.jsx b/play-life-web/src/components/BoardForm.jsx index abd3b74..ada2343 100644 --- a/play-life-web/src/components/BoardForm.jsx +++ b/play-life-web/src/components/BoardForm.jsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import BoardMembers from './BoardMembers' import Toast from './Toast' -import DeleteButton from './DeleteButton' import './Buttons.css' import './BoardForm.css' +import './Wishlist.css' function BoardForm({ boardId, onNavigate, onSaved, isActive }) { const { authFetch } = useAuth() @@ -19,6 +19,9 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { const [toastMessage, setToastMessage] = useState(null) const [isOwner, setIsOwner] = useState(true) const [isArchived, setIsArchived] = useState(false) + const [showActionMenu, setShowActionMenu] = useState(false) + const actionMenuHistoryRef = useRef(false) + const savedHistoryStateRef = useRef(null) 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 () => { if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return @@ -147,8 +161,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - // Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную - onNavigate('wishlist', { boardDeleted: true }) + navigateBackFromActionMenu() } else { setToastMessage({ text: 'Ошибка удаления', type: 'error' }) setIsDeleting(false) @@ -168,7 +181,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - onNavigate('wishlist', { boardDeleted: true }) + onNavigate('wishlist', { boardDeleted: true }, { replace: true }) } else { setToastMessage({ text: 'Ошибка выхода', type: 'error' }) } @@ -186,7 +199,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - onNavigate('wishlist', { boardDeleted: true }) + navigateBackFromActionMenu() } else { setToastMessage({ text: 'Ошибка архивации', type: 'error' }) } @@ -201,9 +214,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { method: 'POST' }) if (res.ok) { - setIsArchived(false) onSaved?.() - setToastMessage({ text: 'Доска разархивирована', type: 'success' }) + navigateBackFromActionMenu() } else { 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 = () => { window.history.back() } @@ -229,58 +277,6 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { ) } - // Для не-владельца показываем упрощённую форму - if (isEdit && !isOwner) { - return ( -
- - -

{name}

- -
-
- {isArchived ? ( - - ) : ( - - )} - -
-
- - {toastMessage && ( - setToastMessage(null)} - /> - )} -
- ) - } - return (
- ) : ( - - )} -
)} @@ -429,16 +404,61 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { {loading ? 'Сохранение...' : 'Сохранить'} {isEdit && ( - + )} , document.body ) : null} + {showActionMenu && createPortal( +
+
e.stopPropagation()}> +
+

{name}

+
+
+ {isArchived ? ( + + ) : ( + + )} + +
+
+
, + document.body + )} ) } diff --git a/play-life-web/src/components/ShoppingBoardForm.jsx b/play-life-web/src/components/ShoppingBoardForm.jsx index 24211b3..3a285a6 100644 --- a/play-life-web/src/components/ShoppingBoardForm.jsx +++ b/play-life-web/src/components/ShoppingBoardForm.jsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import BoardMembers from './BoardMembers' import Toast from './Toast' -import DeleteButton from './DeleteButton' import './Buttons.css' import './BoardForm.css' +import './Wishlist.css' function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { const { authFetch } = useAuth() @@ -19,6 +19,9 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { const [toastMessage, setToastMessage] = useState(null) const [isOwner, setIsOwner] = useState(true) const [isArchived, setIsArchived] = useState(false) + const [showActionMenu, setShowActionMenu] = useState(false) + const actionMenuHistoryRef = useRef(false) + const savedHistoryStateRef = useRef(null) 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 () => { if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return @@ -142,7 +156,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - onNavigate('shopping', { boardDeleted: true }) + navigateBackFromActionMenu() } else { setToastMessage({ text: 'Ошибка удаления', type: 'error' }) setIsDeleting(false) @@ -162,7 +176,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - onNavigate('shopping', { boardDeleted: true }) + onNavigate('shopping', { boardDeleted: true }, { replace: true }) } else { setToastMessage({ text: 'Ошибка выхода', type: 'error' }) } @@ -180,7 +194,7 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { }) if (res.ok) { onSaved?.() - onNavigate('shopping', { boardDeleted: true }) + navigateBackFromActionMenu() } else { setToastMessage({ text: 'Ошибка архивации', type: 'error' }) } @@ -195,9 +209,8 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { method: 'POST' }) if (res.ok) { - setIsArchived(false) onSaved?.() - setToastMessage({ text: 'Доска разархивирована', type: 'success' }) + navigateBackFromActionMenu() } else { 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 = () => { window.history.back() } @@ -223,58 +272,6 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { ) } - // Для не-владельца показываем упрощённую форму - if (isEdit && !isOwner) { - return ( -
- - -

{name}

- -
-
- {isArchived ? ( - - ) : ( - - )} - -
-
- - {toastMessage && ( - setToastMessage(null)} - /> - )} -
- ) - } - return (
- ) : ( - - )} -
)} @@ -422,16 +398,61 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { {loading ? 'Сохранение...' : 'Сохранить'} {isEdit && ( - + )} , document.body ) : null} + {showActionMenu && createPortal( +
+
e.stopPropagation()}> +
+

{name}

+
+
+ {isArchived ? ( + + ) : ( + + )} + +
+
+
, + document.body + )} ) } diff --git a/play-life-web/src/components/ShoppingList.jsx b/play-life-web/src/components/ShoppingList.jsx index f21ea82..4901cdc 100644 --- a/play-life-web/src/components/ShoppingList.jsx +++ b/play-life-web/src/components/ShoppingList.jsx @@ -11,6 +11,7 @@ import 'react-day-picker/style.css' import './TaskList.css' import './TaskDetail.css' import './ShoppingList.css' +import './Wishlist.css' const BOARDS_CACHE_KEY = 'shopping_boards_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 [isPostponing, setIsPostponing] = useState(false) const [toast, setToast] = useState(null) + const [showBoardActionMenu, setShowBoardActionMenu] = useState(false) const initialFetchDoneRef = useRef(false) const prevIsActiveRef = useRef(isActive) const itemsAbortRef = useRef(null) @@ -388,15 +390,77 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia const handleBoardEdit = (boardId) => { const id = boardId || selectedBoardId - if (id) { - onNavigate('shopping-board-form', { boardId: id }) + if (!id) return + const board = boards.find(b => b.id === id) + if (board && !board.is_owner) { + openBoardActionMenu() + return } + onNavigate('shopping-board-form', { boardId: id }) } const handleAddBoard = () => { 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 = () => { if (selectedBoardId) fetchItems(selectedBoardId) } @@ -941,6 +1005,31 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia onClose={() => setToast(null)} /> )} + + {showBoardActionMenu && createPortal( +
+
e.stopPropagation()}> +
+

{boards.find(b => b.id === selectedBoardId)?.name}

+
+
+ {boards.find(b => b.id === selectedBoardId)?.is_archived ? ( + + ) : ( + + )} + +
+
+
, + document.body + )} ) } diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index be65d0f..8618a22 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' +import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import BoardSelector from './BoardSelector' import LoadingError from './LoadingError' @@ -47,6 +48,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa const [selectedItem, setSelectedItem] = useState(null) const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null) const [currentWeekData, setCurrentWeekData] = useState(null) + const [showBoardActionMenu, setShowBoardActionMenu] = useState(false) const fetchingRef = useRef(false) const fetchingCompletedRef = useRef(false) const initialFetchDoneRef = useRef(false) @@ -393,13 +395,77 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa } 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 = () => { 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 newExpanded = !completedExpanded setCompletedExpanded(newExpanded) @@ -789,6 +855,31 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa onClose={handleCloseDetail} /> )} + + {showBoardActionMenu && createPortal( +
+
e.stopPropagation()}> +
+

{boards.find(b => b.id === selectedBoardId)?.name}

+
+
+ {boards.find(b => b.id === selectedBoardId)?.is_archived ? ( + + ) : ( + + )} + +
+
+
, + document.body + )} ) }