6.23.0: Архивация досок желаний и товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-19 18:27:19 +03:00
parent f1c12fd81a
commit 101f4e27ed
15 changed files with 1381 additions and 447 deletions

View File

@@ -1 +1 @@
6.22.0
6.23.0

View File

@@ -576,6 +576,7 @@ type WishlistBoard struct {
InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -4914,6 +4915,7 @@ func main() {
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/archived", app.getArchivedBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS")
@@ -4921,6 +4923,8 @@ func main() {
protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/archive", app.archiveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/unarchive", app.unarchiveBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS")
@@ -4930,6 +4934,7 @@ func main() {
// Shopping Boards (Товары)
protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards", app.createShoppingBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/archived", app.getArchivedShoppingBoardsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}", app.getShoppingBoardHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "OPTIONS")
@@ -4937,6 +4942,8 @@ func main() {
protected.HandleFunc("/api/shopping/boards/{id}/members", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/leave", app.leaveShoppingBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/archive", app.archiveShoppingBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/unarchive", app.unarchiveShoppingBoardHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.getShoppingItemsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "OPTIONS")
@@ -16072,7 +16079,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
boards := []WishlistBoard{}
// Получаем свои доски + доски где пользователь участник
// Получаем свои доски + доски где пользователь участник (с флагом архивации)
rows, err := a.DB.Query(`
SELECT DISTINCT
wb.id,
@@ -16083,7 +16090,8 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
wb.invite_token,
wb.created_at,
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
(wb.owner_id = $1) as is_owner
(wb.owner_id = $1) as is_owner,
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'wishlist' AND ba.board_id = wb.id) as is_archived
FROM wishlist_boards wb
JOIN users u ON wb.owner_id = u.id
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
@@ -16113,6 +16121,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
&board.IsArchived,
)
if err != nil {
log.Printf("Error scanning board: %v", err)
@@ -16264,6 +16273,11 @@ func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Проверяем, архивирована ли доска для пользователя
var isArchived bool
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2)`, userID, boardID).Scan(&isArchived)
board.IsArchived = isArchived
// Invite token и URL только для владельца
if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String
@@ -16638,6 +16652,158 @@ func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// archiveBoardHandler архивирует доску желаний для текущего пользователя
func (a *App) archiveBoardHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
boardID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
return
}
// Проверяем что пользователь имеет доступ к доске (владелец или участник)
var ownerID int
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
return
}
if ownerID != userID {
var memberCount int
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
if memberCount == 0 {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
}
_, err = a.DB.Exec(`
INSERT INTO board_archives (user_id, board_type, board_id)
VALUES ($1, 'wishlist', $2)
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
`, userID, boardID)
if err != nil {
log.Printf("Error archiving board: %v", err)
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// unarchiveBoardHandler разархивирует доску желаний для текущего пользователя
func (a *App) unarchiveBoardHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
boardID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
return
}
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2`, userID, boardID)
if err != nil {
log.Printf("Error unarchiving board: %v", err)
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// getArchivedBoardsHandler возвращает архивированные доски желаний пользователя
func (a *App) getArchivedBoardsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
boards := []WishlistBoard{}
rows, err := a.DB.Query(`
SELECT DISTINCT
wb.id,
wb.owner_id,
COALESCE(u.name, u.email) as owner_name,
wb.name,
wb.invite_enabled,
wb.invite_token,
wb.created_at,
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
(wb.owner_id = $1) as is_owner
FROM wishlist_boards wb
JOIN users u ON wb.owner_id = u.id
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
JOIN board_archives ba ON ba.board_id = wb.id AND ba.board_type = 'wishlist' AND ba.user_id = $1
WHERE wb.deleted = FALSE
AND (wb.owner_id = $1 OR wbm.user_id = $1)
ORDER BY wb.created_at DESC
`, userID)
if err != nil {
log.Printf("Error getting archived boards: %v", err)
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var board WishlistBoard
var inviteToken sql.NullString
err := rows.Scan(
&board.ID,
&board.OwnerID,
&board.OwnerName,
&board.Name,
&board.InviteEnabled,
&inviteToken,
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
)
if err != nil {
log.Printf("Error scanning archived board: %v", err)
continue
}
boards = append(boards, board)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(boards)
}
// getBoardInviteInfoHandler возвращает информацию о доске по invite token
func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
@@ -19023,6 +19189,7 @@ type ShoppingBoard struct {
InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -19097,7 +19264,8 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
sb.invite_token,
sb.created_at,
(SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count,
(sb.owner_id = $1) as is_owner
(sb.owner_id = $1) as is_owner,
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'shopping' AND ba.board_id = sb.id) as is_archived
FROM shopping_boards sb
JOIN users u ON sb.owner_id = u.id
LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id
@@ -19127,6 +19295,7 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
&board.IsArchived,
)
if err != nil {
log.Printf("Error scanning shopping board: %v", err)
@@ -19275,6 +19444,11 @@ func (a *App) getShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Проверяем, архивирована ли доска для пользователя
var isShoppingArchived bool
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2)`, userID, boardID).Scan(&isShoppingArchived)
board.IsArchived = isShoppingArchived
if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String
baseURL := getEnv("WEBHOOK_BASE_URL", "")
@@ -19637,6 +19811,157 @@ func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// archiveShoppingBoardHandler архивирует доску покупок для текущего пользователя
func (a *App) archiveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
boardID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
return
}
var ownerID int
err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
return
}
if ownerID != userID {
var memberCount int
a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
if memberCount == 0 {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
}
_, err = a.DB.Exec(`
INSERT INTO board_archives (user_id, board_type, board_id)
VALUES ($1, 'shopping', $2)
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
`, userID, boardID)
if err != nil {
log.Printf("Error archiving shopping board: %v", err)
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// unarchiveShoppingBoardHandler разархивирует доску покупок для текущего пользователя
func (a *App) unarchiveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
boardID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
return
}
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2`, userID, boardID)
if err != nil {
log.Printf("Error unarchiving shopping board: %v", err)
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// getArchivedShoppingBoardsHandler возвращает архивированные доски покупок пользователя
func (a *App) getArchivedShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
boards := []ShoppingBoard{}
rows, err := a.DB.Query(`
SELECT DISTINCT
sb.id,
sb.owner_id,
COALESCE(u.name, u.email) as owner_name,
sb.name,
sb.invite_enabled,
sb.invite_token,
sb.created_at,
(SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count,
(sb.owner_id = $1) as is_owner
FROM shopping_boards sb
JOIN users u ON sb.owner_id = u.id
LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id
JOIN board_archives ba ON ba.board_id = sb.id AND ba.board_type = 'shopping' AND ba.user_id = $1
WHERE sb.deleted = FALSE
AND (sb.owner_id = $1 OR sbm.user_id = $1)
ORDER BY sb.created_at DESC
`, userID)
if err != nil {
log.Printf("Error getting archived shopping boards: %v", err)
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var board ShoppingBoard
var inviteToken sql.NullString
err := rows.Scan(
&board.ID,
&board.OwnerID,
&board.OwnerName,
&board.Name,
&board.InviteEnabled,
&inviteToken,
&board.CreatedAt,
&board.MemberCount,
&board.IsOwner,
)
if err != nil {
log.Printf("Error scanning archived shopping board: %v", err)
continue
}
boards = append(boards, board)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(boards)
}
// getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token
func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS board_archives;

View File

@@ -0,0 +1,10 @@
CREATE TABLE board_archives (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
board_type VARCHAR(20) NOT NULL, -- 'wishlist' or 'shopping'
board_id INTEGER NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, board_type, board_id)
);
CREATE INDEX idx_board_archives_user_type ON board_archives(user_id, board_type);

View File

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

View File

@@ -0,0 +1,101 @@
.archived-boards {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.archived-boards h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.archived-boards-loading {
display: flex;
justify-content: center;
padding: 3rem 0;
}
.archived-boards-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 3rem 0;
color: #9ca3af;
font-size: 0.95rem;
}
.archived-boards-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.archived-board-card {
display: flex;
align-items: center;
background: white;
border-radius: 0.5rem;
padding: 0.875rem 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
gap: 0.75rem;
}
.archived-board-info {
flex: 1;
min-width: 0;
cursor: pointer;
}
.archived-board-name {
font-weight: 500;
color: #1f2937;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.archived-board-meta {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.15rem;
}
.archived-board-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.archived-board-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
background: transparent;
}
.archived-board-restore {
color: #6366f1;
}
.archived-board-restore:hover {
background: #eef2ff;
}
.archived-board-delete {
color: #ef4444;
}
.archived-board-delete:hover {
background: #fef2f2;
}

View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import './ArchivedBoards.css'
function ArchivedBoards({ boardType, onNavigate, onSaved }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
const [loading, setLoading] = useState(true)
const [toastMessage, setToastMessage] = useState(null)
const isWishlist = boardType === 'wishlist'
const apiBase = isWishlist ? '/api/wishlist' : '/api/shopping'
const returnTab = isWishlist ? 'wishlist' : 'shopping'
useEffect(() => {
fetchArchivedBoards()
}, [])
const fetchArchivedBoards = async () => {
setLoading(true)
try {
const res = await authFetch(`${apiBase}/boards/archived`)
if (res.ok) {
const data = await res.json()
setBoards(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error fetching archived boards:', err)
} finally {
setLoading(false)
}
}
const handleUnarchive = async (boardId) => {
try {
const res = await authFetch(`${apiBase}/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
setBoards(prev => prev.filter(b => b.id !== boardId))
setToastMessage({ text: 'Доска восстановлена', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка восстановления', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка восстановления', type: 'error' })
}
}
const handleDelete = async (board) => {
if (board.is_owner) {
if (!window.confirm(`Удалить доску "${board.name}"? Все ${isWishlist ? 'желания' : 'товары'} на ней будут удалены.`)) return
try {
const res = await authFetch(`${apiBase}/boards/${board.id}`, {
method: 'DELETE'
})
if (res.ok) {
setBoards(prev => prev.filter(b => b.id !== board.id))
setToastMessage({ text: 'Доска удалена', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
} else {
if (!window.confirm(`Покинуть доску "${board.name}"?`)) return
try {
const res = await authFetch(`${apiBase}/boards/${board.id}/leave`, {
method: 'POST'
})
if (res.ok) {
// Также убираем из архива
await authFetch(`${apiBase}/boards/${board.id}/unarchive`, { method: 'POST' })
setBoards(prev => prev.filter(b => b.id !== board.id))
setToastMessage({ text: 'Вы покинули доску', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
}
}
const handleOpenBoard = (boardId) => {
onNavigate(returnTab, { boardId })
}
const handleClose = () => {
window.history.back()
}
return (
<div className="archived-boards">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Архив</h2>
{loading ? (
<div className="archived-boards-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : boards.length === 0 ? (
<div className="archived-boards-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" strokeWidth="1.5" 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>
<p>Архив пуст</p>
</div>
) : (
<div className="archived-boards-list">
{boards.map(board => (
<div key={board.id} className="archived-board-card">
<div className="archived-board-info" onClick={() => handleOpenBoard(board.id)}>
<div className="archived-board-name">{board.name}</div>
<div className="archived-board-meta">
{board.is_owner ? 'Моя доска' : `Доска ${board.owner_name}`}
{' · '}
{board.member_count + 1} {board.member_count + 1 === 1 ? 'участник' : 'участников'}
</div>
</div>
<div className="archived-board-actions">
<button
className="archived-board-btn archived-board-restore"
onClick={() => handleUnarchive(board.id)}
title="Восстановить"
>
<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>
</button>
<button
className="archived-board-btn archived-board-delete"
onClick={() => handleDelete(board)}
title={board.is_owner ? 'Удалить' : 'Покинуть'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default ArchivedBoards

View File

@@ -130,3 +130,38 @@
font-size: 0.85rem;
color: #6b7280;
}
/* Board action buttons (for archive, leave) */
.board-actions-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.board-action-button {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.875rem 0.5rem;
background: none;
border: none;
border-radius: 0.375rem;
font-size: 0.95rem;
color: #374151;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.board-action-button:hover {
background: #f3f4f6;
}
.board-action-button.board-action-danger {
color: #ef4444;
}
.board-action-button.board-action-danger:hover {
background: #fef2f2;
}

View File

@@ -17,6 +17,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const [isArchived, setIsArchived] = useState(false)
const isEdit = !!boardId
@@ -35,6 +37,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
setIsOwner(data.is_owner)
setIsArchived(data.is_archived || false)
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
@@ -155,6 +159,59 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
}
}
const handleLeave = async () => {
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её желания.')) return
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/leave`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('wishlist', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
}
const handleArchive = async () => {
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/archive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('wishlist', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
}
const handleUnarchive = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
setIsArchived(false)
onSaved?.()
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
} else {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
}
const handleClose = () => {
window.history.back()
}
@@ -172,6 +229,58 @@ 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 (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
@@ -249,6 +358,28 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
{/* Архивирование */}
<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>
</>
)}
@@ -313,4 +444,3 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
}
export default BoardForm

View File

@@ -263,3 +263,64 @@
width: 20px;
height: 20px;
}
/* Секция архива в дропдауне */
.archive-section {
margin-top: 2px;
border-top: 1px solid #f3f4f6;
}
.dropdown-item.archive-toggle {
color: #6b7280;
font-weight: 500;
gap: 12px;
justify-content: flex-start;
}
.dropdown-item.archive-toggle:hover {
background: #f3f4f6;
color: #374151;
}
.dropdown-item.archive-toggle svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.archive-toggle-icon {
margin-left: auto;
font-size: 10px;
color: #9ca3af;
}
.archive-list {
padding: 0 4px 4px;
}
.archive-loading {
display: flex;
justify-content: center;
padding: 12px 0;
}
.archive-empty {
padding: 10px 16px;
text-align: center;
color: #9ca3af;
font-size: 14px;
}
.dropdown-item.archive-item {
padding: 14px 16px;
color: #9ca3af;
}
.dropdown-item.archive-item:hover {
background: #f3f4f6;
color: #6b7280;
}
.archive-item-name {
font-weight: 500;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import './BoardSelector.css'
function BoardSelector({
@@ -11,8 +11,12 @@ function BoardSelector({
showBoardAction = true
}) {
const [isOpen, setIsOpen] = useState(false)
const [archiveExpanded, setArchiveExpanded] = useState(false)
const dropdownRef = useRef(null)
const activeBoards = useMemo(() => boards.filter(b => !b.is_archived), [boards])
const archivedBoards = useMemo(() => boards.filter(b => b.is_archived), [boards])
const selectedBoard = boards.find(b => b.id === selectedBoardId)
// Закрытие при клике снаружи
@@ -26,6 +30,12 @@ function BoardSelector({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
if (!isOpen) {
setArchiveExpanded(false)
}
}, [isOpen])
const handleSelectBoard = (board) => {
onBoardChange(board.id)
setIsOpen(false)
@@ -53,23 +63,15 @@ function BoardSelector({
className="pill-action-btn"
role="button"
tabIndex={0}
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
title="Настройки доски"
onClick={handleBoardAction}
onKeyDown={(e) => e.key === 'Enter' && handleBoardAction(e)}
>
{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>
)}
<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>
</span>
)}
</button>
@@ -77,13 +79,13 @@ function BoardSelector({
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
<div className="dropdown-content">
{boards.length === 0 ? (
{activeBoards.length === 0 && archivedBoards.length === 0 ? (
<div className="dropdown-empty">
Нет досок
</div>
) : (
<div className="dropdown-list">
{boards.map(board => (
{activeBoards.map(board => (
<button
key={board.id}
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
@@ -107,30 +109,37 @@ function BoardSelector({
<span>Создать доску</span>
</button>
{selectedBoard && showBoardAction && (
<button
className="dropdown-item board-action-item"
onClick={(e) => { setIsOpen(false); onBoardEdit() }}
>
{selectedBoard.is_owner ? (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Настройки доски</span>
</>
) : (
<>
<svg width="16" height="16" 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>
</>
{archivedBoards.length > 0 && (
<div className="archive-section">
<button
className="dropdown-item archive-toggle"
onClick={() => setArchiveExpanded(!archiveExpanded)}
>
<svg width="16" height="16" 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>
<span className="archive-toggle-icon">
{archiveExpanded ? '▼' : '▶'}
</span>
</button>
{archiveExpanded && (
<div className="archive-list">
{archivedBoards.map(board => (
<button
key={board.id}
className={`dropdown-item archive-item ${board.id === selectedBoardId ? 'selected' : ''}`}
onClick={() => handleSelectBoard(board)}
>
<span className="item-name archive-item-name">{board.name}</span>
</button>
))}
</div>
)}
</button>
</div>
)}
</div>
</div>

View File

@@ -17,6 +17,8 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const [isArchived, setIsArchived] = useState(false)
const isEdit = !!boardId
@@ -35,6 +37,8 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
setIsOwner(data.is_owner)
setIsArchived(data.is_archived || false)
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
@@ -149,6 +153,59 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
}
}
const handleLeave = async () => {
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/leave`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('shopping', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
}
const handleArchive = async () => {
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/archive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('shopping', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
}
const handleUnarchive = async () => {
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
setIsArchived(false)
onSaved?.()
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
} else {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
}
const handleClose = () => {
window.history.back()
}
@@ -166,6 +223,58 @@ 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 (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
@@ -242,6 +351,28 @@ function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
{/* Архивирование */}
<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>
</>
)}

View File

@@ -386,21 +386,10 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
setSelectedBoardId(boardId)
}
const handleBoardEdit = () => {
if (selectedBoardId) {
const board = boards.find(b => b.id === selectedBoardId)
if (board?.is_owner) {
onNavigate('shopping-board-form', { boardId: selectedBoardId })
} else {
if (window.confirm('Покинуть доску?')) {
authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, { method: 'POST' })
.then(res => {
if (res.ok) {
fetchBoards()
}
})
}
}
const handleBoardEdit = (boardId) => {
const id = boardId || selectedBoardId
if (id) {
onNavigate('shopping-board-form', { boardId: id })
}
}

View File

@@ -148,20 +148,20 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
setBoards(data || [])
saveBoardsToCache(data || [])
const firstActive = data?.find(b => !b.is_archived) || (data?.length > 0 ? data[0] : null)
// Проверяем, что выбранная доска существует в списке
if (selectedBoardId) {
const boardExists = data?.some(b => b.id === selectedBoardId)
if (!boardExists && data?.length > 0) {
// Сохранённая доска не существует, выбираем первую
setSelectedBoardId(data[0].id)
if (!boardExists && firstActive) {
setSelectedBoardId(firstActive.id)
}
} else if (data?.length > 0) {
} else if (firstActive) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data[0].id)
setSelectedBoardId(firstActive.id)
}
}
}
@@ -377,12 +377,13 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
}
}, [boardDeleted])
// Если текущая доска больше не существует в списке - выбираем первую
// Если текущая доска больше не существует в списке - выбираем первую неархивную
useEffect(() => {
if (boards.length > 0 && selectedBoardId) {
const boardExists = boards.some(b => b.id === selectedBoardId)
if (!boardExists) {
setSelectedBoardId(boards[0].id)
const firstActive = boards.find(b => !b.is_archived) || boards[0]
setSelectedBoardId(firstActive.id)
}
}
}, [boards, selectedBoardId])
@@ -391,41 +392,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
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 handleBoardEdit = (boardId) => {
onNavigate?.('board-form', { boardId: boardId || selectedBoardId })
}
const handleAddBoard = () => {
@@ -712,6 +680,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
archivedApiUrl="/api/wishlist/boards/archived"
onBoardUnarchived={() => fetchBoards()}
loading={boardsLoading}
showBoardAction={false}
/>

5
run.sh
View File

@@ -46,9 +46,10 @@ if docker-compose ps | grep -q "Up"; then
echo " - Backend сервер (с пересборкой)"
echo " - Frontend приложение (с пересборкой)"
echo " - База данных"
# Пересобираем и перезапускаем (BuildKit надёжно отслеживает изменения файлов)
# Пересобираем без кэша и перезапускаем
echo -e "${BLUE}Пересборка и перезапуск сервисов...${NC}"
docker-compose up -d --build --force-recreate play-life-web backend
docker-compose build --no-cache play-life-web backend
docker-compose up -d --force-recreate play-life-web backend
# Перезапускаем базу данных
docker-compose restart db
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"