6.23.0: Архивация досок желаний и товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
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:
@@ -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" {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS board_archives;
|
||||||
10
play-life-backend/migrations/000033_board_archives.up.sql
Normal file
10
play-life-backend/migrations/000033_board_archives.up.sql
Normal 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);
|
||||||
@@ -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",
|
||||||
|
|||||||
101
play-life-web/src/components/ArchivedBoards.css
Normal file
101
play-life-web/src/components/ArchivedBoards.css
Normal 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;
|
||||||
|
}
|
||||||
171
play-life-web/src/components/ArchivedBoards.jsx
Normal file
171
play-life-web/src/components/ArchivedBoards.jsx
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
5
run.sh
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user