diff --git a/VERSION b/VERSION index fe67504..0b31cc6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.22.0 +6.23.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index a0b8228..247ecd0 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -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,11 +16090,12 @@ 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 - WHERE wb.deleted = FALSE + WHERE wb.deleted = FALSE AND (wb.owner_id = $1 OR wbm.user_id = $1) ORDER BY is_owner DESC, wb.created_at DESC `, userID) @@ -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" { diff --git a/play-life-backend/migrations/000033_board_archives.down.sql b/play-life-backend/migrations/000033_board_archives.down.sql new file mode 100644 index 0000000..956013b --- /dev/null +++ b/play-life-backend/migrations/000033_board_archives.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS board_archives; diff --git a/play-life-backend/migrations/000033_board_archives.up.sql b/play-life-backend/migrations/000033_board_archives.up.sql new file mode 100644 index 0000000..86b6f45 --- /dev/null +++ b/play-life-backend/migrations/000033_board_archives.up.sql @@ -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); diff --git a/play-life-web/package.json b/play-life-web/package.json index 5b517f7..a33b572 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.22.0", + "version": "6.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/ArchivedBoards.css b/play-life-web/src/components/ArchivedBoards.css new file mode 100644 index 0000000..9fd5763 --- /dev/null +++ b/play-life-web/src/components/ArchivedBoards.css @@ -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; +} diff --git a/play-life-web/src/components/ArchivedBoards.jsx b/play-life-web/src/components/ArchivedBoards.jsx new file mode 100644 index 0000000..6976d8e --- /dev/null +++ b/play-life-web/src/components/ArchivedBoards.jsx @@ -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 ( +
+ + +

Архив

+ + {loading ? ( +
+
+
+ ) : boards.length === 0 ? ( +
+ + + + + +

Архив пуст

+
+ ) : ( +
+ {boards.map(board => ( +
+
handleOpenBoard(board.id)}> +
{board.name}
+
+ {board.is_owner ? 'Моя доска' : `Доска ${board.owner_name}`} + {' · '} + {board.member_count + 1} {board.member_count + 1 === 1 ? 'участник' : 'участников'} +
+
+
+ + +
+
+ ))} +
+ )} + + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default ArchivedBoards diff --git a/play-life-web/src/components/BoardForm.css b/play-life-web/src/components/BoardForm.css index 84df251..1c4c440 100644 --- a/play-life-web/src/components/BoardForm.css +++ b/play-life-web/src/components/BoardForm.css @@ -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; +} diff --git a/play-life-web/src/components/BoardForm.jsx b/play-life-web/src/components/BoardForm.jsx index 24f9671..abd3b74 100644 --- a/play-life-web/src/components/BoardForm.jsx +++ b/play-life-web/src/components/BoardForm.jsx @@ -17,15 +17,17 @@ 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 - + useEffect(() => { if (boardId) { fetchBoard() } }, [boardId]) - + const fetchBoard = async () => { setLoadingBoard(true) try { @@ -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' }) } @@ -44,7 +48,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { setLoadingBoard(false) } } - + const handleSave = async () => { if (!name.trim()) { setToastMessage({ text: 'Введите название доски', type: 'error' }) @@ -53,19 +57,19 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { setLoading(true) try { - const url = boardId - ? `/api/wishlist/boards/${boardId}` + const url = boardId + ? `/api/wishlist/boards/${boardId}` : '/api/wishlist/boards' - + const res = await authFetch(url, { method: boardId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: name.trim(), - invite_enabled: inviteEnabled + body: JSON.stringify({ + name: name.trim(), + invite_enabled: inviteEnabled }) }) - + if (res.ok) { const data = await res.json() if (data.invite_url) { @@ -89,7 +93,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { setLoading(false) } } - + // Функция для автоматической генерации ссылки при включении доступа const generateInviteLink = async () => { try { @@ -105,7 +109,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { console.error('Error generating invite link:', err) } } - + const handleCopyLink = () => { navigator.clipboard.writeText(inviteURL) setCopied(true) @@ -115,7 +119,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { const handleToggleInvite = async (enabled) => { setInviteEnabled(enabled) - + if (boardId && enabled && !inviteURL) { // Автоматически генерируем ссылку при включении await generateInviteLink() @@ -132,10 +136,10 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { } } } - + const handleDelete = async () => { if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return - + setIsDeleting(true) try { const res = await authFetch(`/api/wishlist/boards/${boardId}`, { @@ -155,10 +159,63 @@ 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() } - + if (loadingBoard) { return (
@@ -171,19 +228,71 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
) } - + + // Для не-владельца показываем упрощённую форму + if (isEdit && !isOwner) { + return ( +
+ + +

{name}

+ +
+
+ {isArchived ? ( + + ) : ( + + )} + +
+
+ + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) + } + return (
- +

{isEdit ? 'Настройки доски' : 'Новая доска'}

- +
-
- + {isEdit && ( <> {/* Настройки доступа */}

Доступ по ссылке

- + - + {inviteEnabled && inviteURL && (
- -
- + {/* Список участников */} - { setToastMessage({ text: 'Участник удалён', type: 'success' }) }} /> + + {/* Архивирование */} +
+ {isArchived ? ( + + ) : ( + + )} +
)} - +
{toastMessage && ( @@ -313,4 +444,3 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) { } export default BoardForm - diff --git a/play-life-web/src/components/BoardSelector.css b/play-life-web/src/components/BoardSelector.css index 4d9f076..7842cab 100644 --- a/play-life-web/src/components/BoardSelector.css +++ b/play-life-web/src/components/BoardSelector.css @@ -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; +} diff --git a/play-life-web/src/components/BoardSelector.jsx b/play-life-web/src/components/BoardSelector.jsx index 8438b6a..0f5c8f3 100644 --- a/play-life-web/src/components/BoardSelector.jsx +++ b/play-life-web/src/components/BoardSelector.jsx @@ -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 ? ( - - - - - - ) : ( - - - - - - )} + + + + + )} @@ -77,13 +79,13 @@ function BoardSelector({
- {boards.length === 0 ? ( + {activeBoards.length === 0 && archivedBoards.length === 0 ? (
Нет досок
) : (
- {boards.map(board => ( + {activeBoards.map(board => ( - {selectedBoard && showBoardAction && ( - + + {archiveExpanded && ( +
+ {archivedBoards.map(board => ( + + ))} +
)} - +
)}
diff --git a/play-life-web/src/components/ShoppingBoardForm.jsx b/play-life-web/src/components/ShoppingBoardForm.jsx index 0accb01..24211b3 100644 --- a/play-life-web/src/components/ShoppingBoardForm.jsx +++ b/play-life-web/src/components/ShoppingBoardForm.jsx @@ -1,308 +1,439 @@ -import React, { useState, useEffect } 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' - -function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { - const { authFetch } = useAuth() - const [name, setName] = useState('') - const [inviteEnabled, setInviteEnabled] = useState(false) - const [inviteURL, setInviteURL] = useState('') - const [loading, setLoading] = useState(false) - const [loadingBoard, setLoadingBoard] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const [copied, setCopied] = useState(false) - const [toastMessage, setToastMessage] = useState(null) - - const isEdit = !!boardId - - useEffect(() => { - if (boardId) { - fetchBoard() - } - }, [boardId]) - - const fetchBoard = async () => { - setLoadingBoard(true) - try { - const res = await authFetch(`/api/shopping/boards/${boardId}`) - if (res.ok) { - const data = await res.json() - setName(data.name) - setInviteEnabled(data.invite_enabled) - setInviteURL(data.invite_url || '') - } else { - setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' }) - } - } catch (err) { - setToastMessage({ text: 'Ошибка загрузки', type: 'error' }) - } finally { - setLoadingBoard(false) - } - } - - const handleSave = async () => { - if (!name.trim()) { - setToastMessage({ text: 'Введите название доски', type: 'error' }) - return - } - - setLoading(true) - try { - const url = boardId - ? `/api/shopping/boards/${boardId}` - : '/api/shopping/boards' - - const res = await authFetch(url, { - method: boardId ? 'PUT' : 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: name.trim(), - invite_enabled: inviteEnabled - }) - }) - - if (res.ok) { - const data = await res.json() - if (data.invite_url) { - setInviteURL(data.invite_url) - } - onSaved?.() - if (!boardId) { - onNavigate('shopping', { boardId: data.id }) - } else { - onNavigate('shopping', { boardId: boardId }) - } - } else { - const err = await res.json() - setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' }) - } - } catch (err) { - setToastMessage({ text: 'Ошибка сохранения', type: 'error' }) - } finally { - setLoading(false) - } - } - - const generateInviteLink = async () => { - try { - const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, { - method: 'POST' - }) - if (res.ok) { - const data = await res.json() - setInviteURL(data.invite_url) - setInviteEnabled(true) - } - } catch (err) { - console.error('Error generating invite link:', err) - } - } - - const handleCopyLink = () => { - navigator.clipboard.writeText(inviteURL) - setCopied(true) - setToastMessage({ text: 'Ссылка скопирована', type: 'success' }) - setTimeout(() => setCopied(false), 2000) - } - - const handleToggleInvite = async (enabled) => { - setInviteEnabled(enabled) - - if (boardId && enabled && !inviteURL) { - await generateInviteLink() - } else if (boardId) { - try { - await authFetch(`/api/shopping/boards/${boardId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ invite_enabled: enabled }) - }) - } catch (err) { - console.error('Error updating invite status:', err) - } - } - } - - const handleDelete = async () => { - if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return - - setIsDeleting(true) - try { - const res = await authFetch(`/api/shopping/boards/${boardId}`, { - method: 'DELETE' - }) - if (res.ok) { - onSaved?.() - onNavigate('shopping', { boardDeleted: true }) - } else { - setToastMessage({ text: 'Ошибка удаления', type: 'error' }) - setIsDeleting(false) - } - } catch (err) { - setToastMessage({ text: 'Ошибка удаления', type: 'error' }) - setIsDeleting(false) - } - } - - const handleClose = () => { - window.history.back() - } - - if (loadingBoard) { - return ( -
-
-
-
-
Загрузка...
-
-
-
- ) - } - - return ( -
- - -

{isEdit ? 'Настройки доски' : 'Новая доска'}

- -
-
- - setName(e.target.value)} - placeholder="Название доски" - /> -
- - {isEdit && ( - <> -
-

Доступ по ссылке

- - - - {inviteEnabled && inviteURL && ( -
-
- - -
-

- Пользователь, открывший ссылку, сможет присоединиться к доске -

-
- )} -
- - { - setToastMessage({ text: 'Участник удалён', type: 'success' }) - }} - /> - - )} - -
- - {toastMessage && ( - setToastMessage(null)} - /> - )} - - {isActive ? createPortal( -
- - {isEdit && ( - - )} -
, - document.body - ) : null} -
- ) -} - -export default ShoppingBoardForm +import React, { useState, useEffect } 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' + +function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { + const { authFetch } = useAuth() + const [name, setName] = useState('') + const [inviteEnabled, setInviteEnabled] = useState(false) + const [inviteURL, setInviteURL] = useState('') + const [loading, setLoading] = useState(false) + const [loadingBoard, setLoadingBoard] = useState(false) + const [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 + + useEffect(() => { + if (boardId) { + fetchBoard() + } + }, [boardId]) + + const fetchBoard = async () => { + setLoadingBoard(true) + try { + const res = await authFetch(`/api/shopping/boards/${boardId}`) + if (res.ok) { + const data = await res.json() + 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' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка загрузки', type: 'error' }) + } finally { + setLoadingBoard(false) + } + } + + const handleSave = async () => { + if (!name.trim()) { + setToastMessage({ text: 'Введите название доски', type: 'error' }) + return + } + + setLoading(true) + try { + const url = boardId + ? `/api/shopping/boards/${boardId}` + : '/api/shopping/boards' + + const res = await authFetch(url, { + method: boardId ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.trim(), + invite_enabled: inviteEnabled + }) + }) + + if (res.ok) { + const data = await res.json() + if (data.invite_url) { + setInviteURL(data.invite_url) + } + onSaved?.() + if (!boardId) { + onNavigate('shopping', { boardId: data.id }) + } else { + onNavigate('shopping', { boardId: boardId }) + } + } else { + const err = await res.json() + setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка сохранения', type: 'error' }) + } finally { + setLoading(false) + } + } + + const generateInviteLink = async () => { + try { + const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, { + method: 'POST' + }) + if (res.ok) { + const data = await res.json() + setInviteURL(data.invite_url) + setInviteEnabled(true) + } + } catch (err) { + console.error('Error generating invite link:', err) + } + } + + const handleCopyLink = () => { + navigator.clipboard.writeText(inviteURL) + setCopied(true) + setToastMessage({ text: 'Ссылка скопирована', type: 'success' }) + setTimeout(() => setCopied(false), 2000) + } + + const handleToggleInvite = async (enabled) => { + setInviteEnabled(enabled) + + if (boardId && enabled && !inviteURL) { + await generateInviteLink() + } else if (boardId) { + try { + await authFetch(`/api/shopping/boards/${boardId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite_enabled: enabled }) + }) + } catch (err) { + console.error('Error updating invite status:', err) + } + } + } + + const handleDelete = async () => { + if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return + + setIsDeleting(true) + try { + const res = await authFetch(`/api/shopping/boards/${boardId}`, { + method: 'DELETE' + }) + if (res.ok) { + onSaved?.() + onNavigate('shopping', { boardDeleted: true }) + } else { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + setIsDeleting(false) + } + } catch (err) { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + setIsDeleting(false) + } + } + + 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() + } + + if (loadingBoard) { + return ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + // Для не-владельца показываем упрощённую форму + if (isEdit && !isOwner) { + return ( +
+ + +

{name}

+ +
+
+ {isArchived ? ( + + ) : ( + + )} + +
+
+ + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) + } + + return ( +
+ + +

{isEdit ? 'Настройки доски' : 'Новая доска'}

+ +
+
+ + setName(e.target.value)} + placeholder="Название доски" + /> +
+ + {isEdit && ( + <> +
+

Доступ по ссылке

+ + + + {inviteEnabled && inviteURL && ( +
+
+ + +
+

+ Пользователь, открывший ссылку, сможет присоединиться к доске +

+
+ )} +
+ + { + setToastMessage({ text: 'Участник удалён', type: 'success' }) + }} + /> + + {/* Архивирование */} +
+ {isArchived ? ( + + ) : ( + + )} +
+ + )} + +
+ + {toastMessage && ( + setToastMessage(null)} + /> + )} + + {isActive ? createPortal( +
+ + {isEdit && ( + + )} +
, + document.body + ) : null} +
+ ) +} + +export default ShoppingBoardForm diff --git a/play-life-web/src/components/ShoppingList.jsx b/play-life-web/src/components/ShoppingList.jsx index 3865830..f21ea82 100644 --- a/play-life-web/src/components/ShoppingList.jsx +++ b/play-life-web/src/components/ShoppingList.jsx @@ -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 }) } } diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index 7d2dd44..be65d0f 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -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} /> diff --git a/run.sh b/run.sh index 1297460..2800901 100755 --- a/run.sh +++ b/run.sh @@ -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}"