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

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

View File

@@ -1 +1 @@
6.22.0 6.23.0

View File

@@ -576,6 +576,7 @@ type WishlistBoard struct {
InviteURL *string `json:"invite_url,omitempty"` InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"` MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"` IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -4914,6 +4915,7 @@ func main() {
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!) // Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") 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", 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.getBoardHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "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", app.getBoardMembersHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "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}/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.getBoardItemsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "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") protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS")
@@ -4930,6 +4934,7 @@ func main() {
// Shopping Boards (Товары) // Shopping Boards (Товары)
protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS") 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", 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.getShoppingBoardHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "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", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "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}/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.getShoppingItemsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "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{} boards := []WishlistBoard{}
// Получаем свои доски + доски где пользователь участник // Получаем свои доски + доски где пользователь участник (с флагом архивации)
rows, err := a.DB.Query(` rows, err := a.DB.Query(`
SELECT DISTINCT SELECT DISTINCT
wb.id, wb.id,
@@ -16083,11 +16090,12 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
wb.invite_token, wb.invite_token,
wb.created_at, wb.created_at,
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count, (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 FROM wishlist_boards wb
JOIN users u ON wb.owner_id = u.id JOIN users u ON wb.owner_id = u.id
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_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) AND (wb.owner_id = $1 OR wbm.user_id = $1)
ORDER BY is_owner DESC, wb.created_at DESC ORDER BY is_owner DESC, wb.created_at DESC
`, userID) `, userID)
@@ -16113,6 +16121,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
&board.CreatedAt, &board.CreatedAt,
&board.MemberCount, &board.MemberCount,
&board.IsOwner, &board.IsOwner,
&board.IsArchived,
) )
if err != nil { if err != nil {
log.Printf("Error scanning board: %v", err) 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 только для владельца // Invite token и URL только для владельца
if board.IsOwner && inviteToken.Valid { if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String board.InviteToken = &inviteToken.String
@@ -16638,6 +16652,158 @@ func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 // getBoardInviteInfoHandler возвращает информацию о доске по invite token
func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
@@ -19023,6 +19189,7 @@ type ShoppingBoard struct {
InviteURL *string `json:"invite_url,omitempty"` InviteURL *string `json:"invite_url,omitempty"`
MemberCount int `json:"member_count"` MemberCount int `json:"member_count"`
IsOwner bool `json:"is_owner"` IsOwner bool `json:"is_owner"`
IsArchived bool `json:"is_archived,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -19097,7 +19264,8 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
sb.invite_token, sb.invite_token,
sb.created_at, sb.created_at,
(SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count, (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 FROM shopping_boards sb
JOIN users u ON sb.owner_id = u.id JOIN users u ON sb.owner_id = u.id
LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_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.CreatedAt,
&board.MemberCount, &board.MemberCount,
&board.IsOwner, &board.IsOwner,
&board.IsArchived,
) )
if err != nil { if err != nil {
log.Printf("Error scanning shopping board: %v", err) 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 { if board.IsOwner && inviteToken.Valid {
board.InviteToken = &inviteToken.String board.InviteToken = &inviteToken.String
baseURL := getEnv("WEBHOOK_BASE_URL", "") baseURL := getEnv("WEBHOOK_BASE_URL", "")
@@ -19637,6 +19811,157 @@ func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent) 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 // getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token
func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,15 +17,17 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const [isArchived, setIsArchived] = useState(false)
const isEdit = !!boardId const isEdit = !!boardId
useEffect(() => { useEffect(() => {
if (boardId) { if (boardId) {
fetchBoard() fetchBoard()
} }
}, [boardId]) }, [boardId])
const fetchBoard = async () => { const fetchBoard = async () => {
setLoadingBoard(true) setLoadingBoard(true)
try { try {
@@ -35,6 +37,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setName(data.name) setName(data.name)
setInviteEnabled(data.invite_enabled) setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '') setInviteURL(data.invite_url || '')
setIsOwner(data.is_owner)
setIsArchived(data.is_archived || false)
} else { } else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' }) setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
} }
@@ -44,7 +48,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setLoadingBoard(false) setLoadingBoard(false)
} }
} }
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) { if (!name.trim()) {
setToastMessage({ text: 'Введите название доски', type: 'error' }) setToastMessage({ text: 'Введите название доски', type: 'error' })
@@ -53,19 +57,19 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setLoading(true) setLoading(true)
try { try {
const url = boardId const url = boardId
? `/api/wishlist/boards/${boardId}` ? `/api/wishlist/boards/${boardId}`
: '/api/wishlist/boards' : '/api/wishlist/boards'
const res = await authFetch(url, { const res = await authFetch(url, {
method: boardId ? 'PUT' : 'POST', method: boardId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.trim(), name: name.trim(),
invite_enabled: inviteEnabled invite_enabled: inviteEnabled
}) })
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
if (data.invite_url) { if (data.invite_url) {
@@ -89,7 +93,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
setLoading(false) setLoading(false)
} }
} }
// Функция для автоматической генерации ссылки при включении доступа // Функция для автоматической генерации ссылки при включении доступа
const generateInviteLink = async () => { const generateInviteLink = async () => {
try { try {
@@ -105,7 +109,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
console.error('Error generating invite link:', err) console.error('Error generating invite link:', err)
} }
} }
const handleCopyLink = () => { const handleCopyLink = () => {
navigator.clipboard.writeText(inviteURL) navigator.clipboard.writeText(inviteURL)
setCopied(true) setCopied(true)
@@ -115,7 +119,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
const handleToggleInvite = async (enabled) => { const handleToggleInvite = async (enabled) => {
setInviteEnabled(enabled) setInviteEnabled(enabled)
if (boardId && enabled && !inviteURL) { if (boardId && enabled && !inviteURL) {
// Автоматически генерируем ссылку при включении // Автоматически генерируем ссылку при включении
await generateInviteLink() await generateInviteLink()
@@ -132,10 +136,10 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
} }
} }
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
setIsDeleting(true) setIsDeleting(true)
try { try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`, { 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 = () => { const handleClose = () => {
window.history.back() window.history.back()
} }
if (loadingBoard) { if (loadingBoard) {
return ( return (
<div className="board-form"> <div className="board-form">
@@ -171,19 +228,71 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
</div> </div>
) )
} }
// Для не-владельца показываем упрощённую форму
if (isEdit && !isOwner) {
return (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{name}</h2>
<div className="form-card">
<div className="board-actions-list">
{isArchived ? (
<button className="board-action-button" onClick={handleUnarchive}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
<span>Разархивировать</span>
</button>
) : (
<button className="board-action-button" onClick={handleArchive}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Архивировать</span>
</button>
)}
<button className="board-action-button board-action-danger" onClick={handleLeave}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
<span>Покинуть доску</span>
</button>
</div>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
return ( return (
<div className="board-form"> <div className="board-form">
<button className="close-x-button" onClick={handleClose}> <button className="close-x-button" onClick={handleClose}>
</button> </button>
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2> <h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
<div className="form-card"> <div className="form-card">
<div className="form-group"> <div className="form-group">
<label htmlFor="board-name">Название</label> <label htmlFor="board-name">Название</label>
<input <input
id="board-name" id="board-name"
type="text" type="text"
className="form-input" className="form-input"
@@ -192,13 +301,13 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
placeholder="Название доски" placeholder="Название доски"
/> />
</div> </div>
{isEdit && ( {isEdit && (
<> <>
{/* Настройки доступа */} {/* Настройки доступа */}
<div className="form-section"> <div className="form-section">
<h3>Доступ по ссылке</h3> <h3>Доступ по ссылке</h3>
<label className="toggle-field"> <label className="toggle-field">
<input <input
type="checkbox" type="checkbox"
@@ -208,17 +317,17 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
<span className="toggle-slider"></span> <span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span> <span className="toggle-label">Разрешить присоединение по ссылке</span>
</label> </label>
{inviteEnabled && inviteURL && ( {inviteEnabled && inviteURL && (
<div className="invite-link-section"> <div className="invite-link-section">
<div className="invite-url-row"> <div className="invite-url-row">
<input <input
type="text" type="text"
className="invite-url-input" className="invite-url-input"
value={inviteURL} value={inviteURL}
readOnly readOnly
/> />
<button <button
className="copy-btn" className="copy-btn"
onClick={handleCopyLink} onClick={handleCopyLink}
title="Копировать ссылку" title="Копировать ссылку"
@@ -241,17 +350,39 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
</div> </div>
)} )}
</div> </div>
{/* Список участников */} {/* Список участников */}
<BoardMembers <BoardMembers
boardId={boardId} boardId={boardId}
onMemberRemoved={() => { onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' }) setToastMessage({ text: 'Участник удалён', type: 'success' })
}} }}
/> />
{/* Архивирование */}
<div className="form-section">
{isArchived ? (
<button className="board-action-button" onClick={handleUnarchive} style={{ marginTop: '0.5rem' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
<span>Разархивировать</span>
</button>
) : (
<button className="board-action-button" onClick={handleArchive} style={{ marginTop: '0.5rem' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Архивировать</span>
</button>
)}
</div>
</> </>
)} )}
</div> </div>
{toastMessage && ( {toastMessage && (
@@ -313,4 +444,3 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
} }
export default BoardForm export default BoardForm

View File

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

View File

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

View File

@@ -1,308 +1,439 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers' import BoardMembers from './BoardMembers'
import Toast from './Toast' import Toast from './Toast'
import DeleteButton from './DeleteButton' import DeleteButton from './DeleteButton'
import './Buttons.css' import './Buttons.css'
import './BoardForm.css' import './BoardForm.css'
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) { function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false) const [inviteEnabled, setInviteEnabled] = useState(false)
const [inviteURL, setInviteURL] = useState('') const [inviteURL, setInviteURL] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingBoard, setLoadingBoard] = useState(false) const [loadingBoard, setLoadingBoard] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const isEdit = !!boardId const [isArchived, setIsArchived] = useState(false)
useEffect(() => { const isEdit = !!boardId
if (boardId) {
fetchBoard() useEffect(() => {
} if (boardId) {
}, [boardId]) fetchBoard()
}
const fetchBoard = async () => { }, [boardId])
setLoadingBoard(true)
try { const fetchBoard = async () => {
const res = await authFetch(`/api/shopping/boards/${boardId}`) setLoadingBoard(true)
if (res.ok) { try {
const data = await res.json() const res = await authFetch(`/api/shopping/boards/${boardId}`)
setName(data.name) if (res.ok) {
setInviteEnabled(data.invite_enabled) const data = await res.json()
setInviteURL(data.invite_url || '') setName(data.name)
} else { setInviteEnabled(data.invite_enabled)
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' }) setInviteURL(data.invite_url || '')
} setIsOwner(data.is_owner)
} catch (err) { setIsArchived(data.is_archived || false)
setToastMessage({ text: 'Ошибка загрузки', type: 'error' }) } else {
} finally { setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
setLoadingBoard(false) }
} } catch (err) {
} setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
const handleSave = async () => { setLoadingBoard(false)
if (!name.trim()) { }
setToastMessage({ text: 'Введите название доски', type: 'error' }) }
return
} const handleSave = async () => {
if (!name.trim()) {
setLoading(true) setToastMessage({ text: 'Введите название доски', type: 'error' })
try { return
const url = boardId }
? `/api/shopping/boards/${boardId}`
: '/api/shopping/boards' setLoading(true)
try {
const res = await authFetch(url, { const url = boardId
method: boardId ? 'PUT' : 'POST', ? `/api/shopping/boards/${boardId}`
headers: { 'Content-Type': 'application/json' }, : '/api/shopping/boards'
body: JSON.stringify({
name: name.trim(), const res = await authFetch(url, {
invite_enabled: inviteEnabled method: boardId ? 'PUT' : 'POST',
}) headers: { 'Content-Type': 'application/json' },
}) body: JSON.stringify({
name: name.trim(),
if (res.ok) { invite_enabled: inviteEnabled
const data = await res.json() })
if (data.invite_url) { })
setInviteURL(data.invite_url)
} if (res.ok) {
onSaved?.() const data = await res.json()
if (!boardId) { if (data.invite_url) {
onNavigate('shopping', { boardId: data.id }) setInviteURL(data.invite_url)
} else { }
onNavigate('shopping', { boardId: boardId }) onSaved?.()
} if (!boardId) {
} else { onNavigate('shopping', { boardId: data.id })
const err = await res.json() } else {
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' }) onNavigate('shopping', { boardId: boardId })
} }
} catch (err) { } else {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' }) const err = await res.json()
} finally { setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
setLoading(false) }
} } catch (err) {
} setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
const generateInviteLink = async () => { setLoading(false)
try { }
const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, { }
method: 'POST'
}) const generateInviteLink = async () => {
if (res.ok) { try {
const data = await res.json() const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, {
setInviteURL(data.invite_url) method: 'POST'
setInviteEnabled(true) })
} if (res.ok) {
} catch (err) { const data = await res.json()
console.error('Error generating invite link:', err) setInviteURL(data.invite_url)
} setInviteEnabled(true)
} }
} catch (err) {
const handleCopyLink = () => { console.error('Error generating invite link:', err)
navigator.clipboard.writeText(inviteURL) }
setCopied(true) }
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setTimeout(() => setCopied(false), 2000) const handleCopyLink = () => {
} navigator.clipboard.writeText(inviteURL)
setCopied(true)
const handleToggleInvite = async (enabled) => { setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setInviteEnabled(enabled) setTimeout(() => setCopied(false), 2000)
}
if (boardId && enabled && !inviteURL) {
await generateInviteLink() const handleToggleInvite = async (enabled) => {
} else if (boardId) { setInviteEnabled(enabled)
try {
await authFetch(`/api/shopping/boards/${boardId}`, { if (boardId && enabled && !inviteURL) {
method: 'PUT', await generateInviteLink()
headers: { 'Content-Type': 'application/json' }, } else if (boardId) {
body: JSON.stringify({ invite_enabled: enabled }) try {
}) await authFetch(`/api/shopping/boards/${boardId}`, {
} catch (err) { method: 'PUT',
console.error('Error updating invite status:', err) 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 handleDelete = async () => {
const res = await authFetch(`/api/shopping/boards/${boardId}`, { if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
method: 'DELETE'
}) setIsDeleting(true)
if (res.ok) { try {
onSaved?.() const res = await authFetch(`/api/shopping/boards/${boardId}`, {
onNavigate('shopping', { boardDeleted: true }) method: 'DELETE'
} else { })
setToastMessage({ text: 'Ошибка удаления', type: 'error' }) if (res.ok) {
setIsDeleting(false) onSaved?.()
} onNavigate('shopping', { boardDeleted: true })
} catch (err) { } else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' }) setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false) setIsDeleting(false)
} }
} } catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
const handleClose = () => { setIsDeleting(false)
window.history.back() }
} }
if (loadingBoard) { const handleLeave = async () => {
return ( if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
<div className="board-form">
<div className="fixed inset-0 bottom-20 flex justify-center items-center"> try {
<div className="flex flex-col items-center"> const res = await authFetch(`/api/shopping/boards/${boardId}/leave`, {
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div> method: 'POST'
<div className="text-gray-600 font-medium">Загрузка...</div> })
</div> if (res.ok) {
</div> onSaved?.()
</div> onNavigate('shopping', { boardDeleted: true })
) } else {
} setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
return ( } catch (err) {
<div className="board-form"> setToastMessage({ text: 'Ошибка выхода', type: 'error' })
<button className="close-x-button" onClick={handleClose}> }
}
</button>
const handleArchive = async () => {
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2> if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
<div className="form-card"> try {
<div className="form-group"> const res = await authFetch(`/api/shopping/boards/${boardId}/archive`, {
<label htmlFor="board-name">Название</label> method: 'POST'
<input })
id="board-name" if (res.ok) {
type="text" onSaved?.()
className="form-input" onNavigate('shopping', { boardDeleted: true })
value={name} } else {
onChange={e => setName(e.target.value)} setToastMessage({ text: 'Ошибка архивации', type: 'error' })
placeholder="Название доски" }
/> } catch (err) {
</div> setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
{isEdit && ( }
<>
<div className="form-section"> const handleUnarchive = async () => {
<h3>Доступ по ссылке</h3> try {
const res = await authFetch(`/api/shopping/boards/${boardId}/unarchive`, {
<label className="toggle-field"> method: 'POST'
<input })
type="checkbox" if (res.ok) {
checked={inviteEnabled} setIsArchived(false)
onChange={e => handleToggleInvite(e.target.checked)} onSaved?.()
/> setToastMessage({ text: 'Доска разархивирована', type: 'success' })
<span className="toggle-slider"></span> } else {
<span className="toggle-label">Разрешить присоединение по ссылке</span> setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
</label> }
} catch (err) {
{inviteEnabled && inviteURL && ( setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
<div className="invite-link-section"> }
<div className="invite-url-row"> }
<input
type="text" const handleClose = () => {
className="invite-url-input" window.history.back()
value={inviteURL} }
readOnly
/> if (loadingBoard) {
<button return (
className="copy-btn" <div className="board-form">
onClick={handleCopyLink} <div className="fixed inset-0 bottom-20 flex justify-center items-center">
title="Копировать ссылку" <div className="flex flex-col items-center">
> <div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
{copied ? ( <div className="text-gray-600 font-medium">Загрузка...</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </div>
<path d="M20 6L9 17l-5-5"></path> </div>
</svg> </div>
) : ( )
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> }
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> // Для не-владельца показываем упрощённую форму
</svg> if (isEdit && !isOwner) {
)} return (
</button> <div className="board-form">
</div> <button className="close-x-button" onClick={handleClose}>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске </button>
</p>
</div> <h2>{name}</h2>
)}
</div> <div className="form-card">
<div className="board-actions-list">
<BoardMembers {isArchived ? (
boardId={boardId} <button className="board-action-button" onClick={handleUnarchive}>
apiBase="/api/shopping" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
onMemberRemoved={() => { <polyline points="1 4 1 10 7 10"></polyline>
setToastMessage({ text: 'Участник удалён', type: 'success' }) <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
}} </svg>
/> <span>Разархивировать</span>
</> </button>
)} ) : (
<button className="board-action-button" onClick={handleArchive}>
</div> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
{toastMessage && ( <rect x="1" y="3" width="22" height="5"></rect>
<Toast <line x1="10" y1="12" x2="14" y2="12"></line>
message={toastMessage.text} </svg>
type={toastMessage.type} <span>Архивировать</span>
onClose={() => setToastMessage(null)} </button>
/> )}
)} <button className="board-action-button board-action-danger" onClick={handleLeave}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isActive ? createPortal( <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<div style={{ <polyline points="16 17 21 12 16 7"></polyline>
position: 'fixed', <line x1="21" y1="12" x2="9" y2="12"></line>
bottom: 0, </svg>
left: 0, <span>Покинуть доску</span>
right: 0, </button>
padding: '0.75rem 1rem', </div>
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))', </div>
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500, {toastMessage && (
display: 'flex', <Toast
justifyContent: 'center', message={toastMessage.text}
gap: '0.75rem', type={toastMessage.type}
}}> onClose={() => setToastMessage(null)}
<button />
onClick={handleSave} )}
disabled={loading || isDeleting || !name.trim()} </div>
style={{ )
flex: 1, }
maxWidth: '42rem',
padding: '0.875rem', return (
background: (loading || !name.trim()) ? undefined : 'linear-gradient(to right, #10b981, #059669)', <div className="board-form">
backgroundColor: (loading || !name.trim()) ? '#9ca3af' : undefined, <button className="close-x-button" onClick={handleClose}>
color: 'white',
border: 'none', </button>
borderRadius: '0.5rem',
fontSize: '1rem', <h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
fontWeight: 600,
cursor: (loading || isDeleting || !name.trim()) ? 'not-allowed' : 'pointer', <div className="form-card">
opacity: loading ? 0.6 : 1, <div className="form-group">
transition: 'all 0.2s', <label htmlFor="board-name">Название</label>
}} <input
> id="board-name"
{loading ? 'Сохранение...' : 'Сохранить'} type="text"
</button> className="form-input"
{isEdit && ( value={name}
<DeleteButton onChange={e => setName(e.target.value)}
onClick={handleDelete} placeholder="Название доски"
loading={isDeleting} />
disabled={loading} </div>
title="Удалить доску"
/> {isEdit && (
)} <>
</div>, <div className="form-section">
document.body <h3>Доступ по ссылке</h3>
) : null}
</div> <label className="toggle-field">
) <input
} type="checkbox"
checked={inviteEnabled}
export default ShoppingBoardForm onChange={e => handleToggleInvite(e.target.checked)}
/>
<span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span>
</label>
{inviteEnabled && inviteURL && (
<div className="invite-link-section">
<div className="invite-url-row">
<input
type="text"
className="invite-url-input"
value={inviteURL}
readOnly
/>
<button
className="copy-btn"
onClick={handleCopyLink}
title="Копировать ссылку"
>
{copied ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске
</p>
</div>
)}
</div>
<BoardMembers
boardId={boardId}
apiBase="/api/shopping"
onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
{/* Архивирование */}
<div className="form-section">
{isArchived ? (
<button className="board-action-button" onClick={handleUnarchive} style={{ marginTop: '0.5rem' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
<span>Разархивировать</span>
</button>
) : (
<button className="board-action-button" onClick={handleArchive} style={{ marginTop: '0.5rem' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Архивировать</span>
</button>
)}
</div>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
}}>
<button
onClick={handleSave}
disabled={loading || isDeleting || !name.trim()}
style={{
flex: 1,
maxWidth: '42rem',
padding: '0.875rem',
background: (loading || !name.trim()) ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: (loading || !name.trim()) ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: (loading || isDeleting || !name.trim()) ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{isEdit && (
<DeleteButton
onClick={handleDelete}
loading={isDeleting}
disabled={loading}
title="Удалить доску"
/>
)}
</div>,
document.body
) : null}
</div>
)
}
export default ShoppingBoardForm

View File

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

View File

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

5
run.sh
View File

@@ -46,9 +46,10 @@ if docker-compose ps | grep -q "Up"; then
echo " - Backend сервер (с пересборкой)" echo " - Backend сервер (с пересборкой)"
echo " - Frontend приложение (с пересборкой)" echo " - Frontend приложение (с пересборкой)"
echo " - База данных" echo " - База данных"
# Пересобираем и перезапускаем (BuildKit надёжно отслеживает изменения файлов) # Пересобираем без кэша и перезапускаем
echo -e "${BLUE}Пересборка и перезапуск сервисов...${NC}" 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 docker-compose restart db
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}" echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"