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 && (
-
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 && (
-
-
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
- Пользователь, открывший ссылку, сможет присоединиться к доске
-
-
- )}
-
-
-
{
- setToastMessage({ text: 'Участник удалён', type: 'success' })
- }}
- />
- >
- )}
-
-
-
- {toastMessage && (
-
setToastMessage(null)}
- />
- )}
-
- {isActive ? createPortal(
-
-
- {loading ? 'Сохранение...' : 'Сохранить'}
-
- {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 && (
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ Пользователь, открывший ссылку, сможет присоединиться к доске
+
+
+ )}
+
+
+
{
+ setToastMessage({ text: 'Участник удалён', type: 'success' })
+ }}
+ />
+
+ {/* Архивирование */}
+
+ {isArchived ? (
+
+
+ Разархивировать
+
+ ) : (
+
+
+ Архивировать
+
+ )}
+
+ >
+ )}
+
+
+
+ {toastMessage && (
+
setToastMessage(null)}
+ />
+ )}
+
+ {isActive ? createPortal(
+
+
+ {loading ? 'Сохранение...' : 'Сохранить'}
+
+ {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}"