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"`
|
||||
MemberCount int `json:"member_count"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
IsArchived bool `json:"is_archived,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -4914,6 +4915,7 @@ func main() {
|
||||
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
|
||||
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/archived", app.getArchivedBoardsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS")
|
||||
@@ -4921,6 +4923,8 @@ func main() {
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}/archive", app.archiveBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{id}/unarchive", app.unarchiveBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS")
|
||||
@@ -4930,6 +4934,7 @@ func main() {
|
||||
// Shopping Boards (Товары)
|
||||
protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards", app.createShoppingBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/archived", app.getArchivedShoppingBoardsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}", app.getShoppingBoardHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "OPTIONS")
|
||||
@@ -4937,6 +4942,8 @@ func main() {
|
||||
protected.HandleFunc("/api/shopping/boards/{id}/members", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}/leave", app.leaveShoppingBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}/archive", app.archiveShoppingBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{id}/unarchive", app.unarchiveShoppingBoardHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.getShoppingItemsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "OPTIONS")
|
||||
@@ -16072,7 +16079,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
boards := []WishlistBoard{}
|
||||
|
||||
// Получаем свои доски + доски где пользователь участник
|
||||
// Получаем свои доски + доски где пользователь участник (с флагом архивации)
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT DISTINCT
|
||||
wb.id,
|
||||
@@ -16083,11 +16090,12 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
wb.invite_token,
|
||||
wb.created_at,
|
||||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
|
||||
(wb.owner_id = $1) as is_owner
|
||||
(wb.owner_id = $1) as is_owner,
|
||||
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'wishlist' AND ba.board_id = wb.id) as is_archived
|
||||
FROM wishlist_boards wb
|
||||
JOIN users u ON wb.owner_id = u.id
|
||||
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
|
||||
WHERE wb.deleted = FALSE
|
||||
WHERE wb.deleted = FALSE
|
||||
AND (wb.owner_id = $1 OR wbm.user_id = $1)
|
||||
ORDER BY is_owner DESC, wb.created_at DESC
|
||||
`, userID)
|
||||
@@ -16113,6 +16121,7 @@ func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&board.CreatedAt,
|
||||
&board.MemberCount,
|
||||
&board.IsOwner,
|
||||
&board.IsArchived,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning board: %v", err)
|
||||
@@ -16264,6 +16273,11 @@ func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, архивирована ли доска для пользователя
|
||||
var isArchived bool
|
||||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2)`, userID, boardID).Scan(&isArchived)
|
||||
board.IsArchived = isArchived
|
||||
|
||||
// Invite token и URL только для владельца
|
||||
if board.IsOwner && inviteToken.Valid {
|
||||
board.InviteToken = &inviteToken.String
|
||||
@@ -16638,6 +16652,158 @@ func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// archiveBoardHandler архивирует доску желаний для текущего пользователя
|
||||
func (a *App) archiveBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем что пользователь имеет доступ к доске (владелец или участник)
|
||||
var ownerID int
|
||||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var memberCount int
|
||||
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
|
||||
if memberCount == 0 {
|
||||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO board_archives (user_id, board_type, board_id)
|
||||
VALUES ($1, 'wishlist', $2)
|
||||
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
|
||||
`, userID, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error archiving board: %v", err)
|
||||
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// unarchiveBoardHandler разархивирует доску желаний для текущего пользователя
|
||||
func (a *App) unarchiveBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'wishlist' AND board_id = $2`, userID, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error unarchiving board: %v", err)
|
||||
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getArchivedBoardsHandler возвращает архивированные доски желаний пользователя
|
||||
func (a *App) getArchivedBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
boards := []WishlistBoard{}
|
||||
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT DISTINCT
|
||||
wb.id,
|
||||
wb.owner_id,
|
||||
COALESCE(u.name, u.email) as owner_name,
|
||||
wb.name,
|
||||
wb.invite_enabled,
|
||||
wb.invite_token,
|
||||
wb.created_at,
|
||||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
|
||||
(wb.owner_id = $1) as is_owner
|
||||
FROM wishlist_boards wb
|
||||
JOIN users u ON wb.owner_id = u.id
|
||||
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
|
||||
JOIN board_archives ba ON ba.board_id = wb.id AND ba.board_type = 'wishlist' AND ba.user_id = $1
|
||||
WHERE wb.deleted = FALSE
|
||||
AND (wb.owner_id = $1 OR wbm.user_id = $1)
|
||||
ORDER BY wb.created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting archived boards: %v", err)
|
||||
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var board WishlistBoard
|
||||
var inviteToken sql.NullString
|
||||
err := rows.Scan(
|
||||
&board.ID,
|
||||
&board.OwnerID,
|
||||
&board.OwnerName,
|
||||
&board.Name,
|
||||
&board.InviteEnabled,
|
||||
&inviteToken,
|
||||
&board.CreatedAt,
|
||||
&board.MemberCount,
|
||||
&board.IsOwner,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning archived board: %v", err)
|
||||
continue
|
||||
}
|
||||
boards = append(boards, board)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(boards)
|
||||
}
|
||||
|
||||
// getBoardInviteInfoHandler возвращает информацию о доске по invite token
|
||||
func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
@@ -19023,6 +19189,7 @@ type ShoppingBoard struct {
|
||||
InviteURL *string `json:"invite_url,omitempty"`
|
||||
MemberCount int `json:"member_count"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
IsArchived bool `json:"is_archived,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -19097,7 +19264,8 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sb.invite_token,
|
||||
sb.created_at,
|
||||
(SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count,
|
||||
(sb.owner_id = $1) as is_owner
|
||||
(sb.owner_id = $1) as is_owner,
|
||||
EXISTS (SELECT 1 FROM board_archives ba WHERE ba.user_id = $1 AND ba.board_type = 'shopping' AND ba.board_id = sb.id) as is_archived
|
||||
FROM shopping_boards sb
|
||||
JOIN users u ON sb.owner_id = u.id
|
||||
LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id
|
||||
@@ -19127,6 +19295,7 @@ func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&board.CreatedAt,
|
||||
&board.MemberCount,
|
||||
&board.IsOwner,
|
||||
&board.IsArchived,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning shopping board: %v", err)
|
||||
@@ -19275,6 +19444,11 @@ func (a *App) getShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, архивирована ли доска для пользователя
|
||||
var isShoppingArchived bool
|
||||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2)`, userID, boardID).Scan(&isShoppingArchived)
|
||||
board.IsArchived = isShoppingArchived
|
||||
|
||||
if board.IsOwner && inviteToken.Valid {
|
||||
board.InviteToken = &inviteToken.String
|
||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
@@ -19637,6 +19811,157 @@ func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// archiveShoppingBoardHandler архивирует доску покупок для текущего пользователя
|
||||
func (a *App) archiveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID int
|
||||
err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var memberCount int
|
||||
a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID).Scan(&memberCount)
|
||||
if memberCount == 0 {
|
||||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO board_archives (user_id, board_type, board_id)
|
||||
VALUES ($1, 'shopping', $2)
|
||||
ON CONFLICT (user_id, board_type, board_id) DO NOTHING
|
||||
`, userID, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error archiving shopping board: %v", err)
|
||||
sendErrorWithCORS(w, "Error archiving board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// unarchiveShoppingBoardHandler разархивирует доску покупок для текущего пользователя
|
||||
func (a *App) unarchiveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(`DELETE FROM board_archives WHERE user_id = $1 AND board_type = 'shopping' AND board_id = $2`, userID, boardID)
|
||||
if err != nil {
|
||||
log.Printf("Error unarchiving shopping board: %v", err)
|
||||
sendErrorWithCORS(w, "Error unarchiving board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getArchivedShoppingBoardsHandler возвращает архивированные доски покупок пользователя
|
||||
func (a *App) getArchivedShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
boards := []ShoppingBoard{}
|
||||
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT DISTINCT
|
||||
sb.id,
|
||||
sb.owner_id,
|
||||
COALESCE(u.name, u.email) as owner_name,
|
||||
sb.name,
|
||||
sb.invite_enabled,
|
||||
sb.invite_token,
|
||||
sb.created_at,
|
||||
(SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count,
|
||||
(sb.owner_id = $1) as is_owner
|
||||
FROM shopping_boards sb
|
||||
JOIN users u ON sb.owner_id = u.id
|
||||
LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id
|
||||
JOIN board_archives ba ON ba.board_id = sb.id AND ba.board_type = 'shopping' AND ba.user_id = $1
|
||||
WHERE sb.deleted = FALSE
|
||||
AND (sb.owner_id = $1 OR sbm.user_id = $1)
|
||||
ORDER BY sb.created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting archived shopping boards: %v", err)
|
||||
sendErrorWithCORS(w, "Error getting archived boards", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var board ShoppingBoard
|
||||
var inviteToken sql.NullString
|
||||
err := rows.Scan(
|
||||
&board.ID,
|
||||
&board.OwnerID,
|
||||
&board.OwnerName,
|
||||
&board.Name,
|
||||
&board.InviteEnabled,
|
||||
&inviteToken,
|
||||
&board.CreatedAt,
|
||||
&board.MemberCount,
|
||||
&board.IsOwner,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning archived shopping board: %v", err)
|
||||
continue
|
||||
}
|
||||
boards = append(boards, board)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(boards)
|
||||
}
|
||||
|
||||
// getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token
|
||||
func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
|
||||
@@ -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",
|
||||
"version": "6.22.0",
|
||||
"version": "6.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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;
|
||||
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 [copied, setCopied] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
const [isOwner, setIsOwner] = useState(true)
|
||||
const [isArchived, setIsArchived] = useState(false)
|
||||
|
||||
const isEdit = !!boardId
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (boardId) {
|
||||
fetchBoard()
|
||||
}
|
||||
}, [boardId])
|
||||
|
||||
|
||||
const fetchBoard = async () => {
|
||||
setLoadingBoard(true)
|
||||
try {
|
||||
@@ -35,6 +37,8 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
setName(data.name)
|
||||
setInviteEnabled(data.invite_enabled)
|
||||
setInviteURL(data.invite_url || '')
|
||||
setIsOwner(data.is_owner)
|
||||
setIsArchived(data.is_archived || false)
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
||||
}
|
||||
@@ -44,7 +48,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
setLoadingBoard(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
||||
@@ -53,19 +57,19 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = boardId
|
||||
? `/api/wishlist/boards/${boardId}`
|
||||
const url = boardId
|
||||
? `/api/wishlist/boards/${boardId}`
|
||||
: '/api/wishlist/boards'
|
||||
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: boardId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
invite_enabled: inviteEnabled
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
invite_enabled: inviteEnabled
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.invite_url) {
|
||||
@@ -89,7 +93,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Функция для автоматической генерации ссылки при включении доступа
|
||||
const generateInviteLink = async () => {
|
||||
try {
|
||||
@@ -105,7 +109,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
console.error('Error generating invite link:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(inviteURL)
|
||||
setCopied(true)
|
||||
@@ -115,7 +119,7 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
|
||||
const handleToggleInvite = async (enabled) => {
|
||||
setInviteEnabled(enabled)
|
||||
|
||||
|
||||
if (boardId && enabled && !inviteURL) {
|
||||
// Автоматически генерируем ссылку при включении
|
||||
await generateInviteLink()
|
||||
@@ -132,10 +136,10 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
|
||||
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
|
||||
@@ -155,10 +159,63 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её желания.')) return
|
||||
|
||||
try {
|
||||
const res = await authFetch(`/api/wishlist/boards/${boardId}/leave`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('wishlist', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
|
||||
|
||||
try {
|
||||
const res = await authFetch(`/api/wishlist/boards/${boardId}/archive`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('wishlist', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/wishlist/boards/${boardId}/unarchive`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsArchived(false)
|
||||
onSaved?.()
|
||||
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
|
||||
if (loadingBoard) {
|
||||
return (
|
||||
<div className="board-form">
|
||||
@@ -171,19 +228,71 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
</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 (
|
||||
<div className="board-form">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
|
||||
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
||||
|
||||
|
||||
<div className="form-card">
|
||||
<div className="form-group">
|
||||
<label htmlFor="board-name">Название</label>
|
||||
<input
|
||||
<input
|
||||
id="board-name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
@@ -192,13 +301,13 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
placeholder="Название доски"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{isEdit && (
|
||||
<>
|
||||
{/* Настройки доступа */}
|
||||
<div className="form-section">
|
||||
<h3>Доступ по ссылке</h3>
|
||||
|
||||
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -208,17 +317,17 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
<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"
|
||||
<input
|
||||
type="text"
|
||||
className="invite-url-input"
|
||||
value={inviteURL}
|
||||
readOnly
|
||||
value={inviteURL}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
<button
|
||||
className="copy-btn"
|
||||
onClick={handleCopyLink}
|
||||
title="Копировать ссылку"
|
||||
@@ -241,17 +350,39 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Список участников */}
|
||||
<BoardMembers
|
||||
boardId={boardId}
|
||||
<BoardMembers
|
||||
boardId={boardId}
|
||||
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 && (
|
||||
@@ -313,4 +444,3 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
}
|
||||
|
||||
export default BoardForm
|
||||
|
||||
|
||||
@@ -263,3 +263,64 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Секция архива в дропдауне */
|
||||
.archive-section {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.dropdown-item.archive-toggle {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dropdown-item.archive-toggle:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dropdown-item.archive-toggle svg {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.archive-toggle-icon {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.archive-list {
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.archive-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.archive-empty {
|
||||
padding: 10px 16px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown-item.archive-item {
|
||||
padding: 14px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dropdown-item.archive-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.archive-item-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import './BoardSelector.css'
|
||||
|
||||
function BoardSelector({
|
||||
@@ -11,8 +11,12 @@ function BoardSelector({
|
||||
showBoardAction = true
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [archiveExpanded, setArchiveExpanded] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
const activeBoards = useMemo(() => boards.filter(b => !b.is_archived), [boards])
|
||||
const archivedBoards = useMemo(() => boards.filter(b => b.is_archived), [boards])
|
||||
|
||||
const selectedBoard = boards.find(b => b.id === selectedBoardId)
|
||||
|
||||
// Закрытие при клике снаружи
|
||||
@@ -26,6 +30,12 @@ function BoardSelector({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setArchiveExpanded(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelectBoard = (board) => {
|
||||
onBoardChange(board.id)
|
||||
setIsOpen(false)
|
||||
@@ -53,23 +63,15 @@ function BoardSelector({
|
||||
className="pill-action-btn"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
|
||||
title="Настройки доски"
|
||||
onClick={handleBoardAction}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleBoardAction(e)}
|
||||
>
|
||||
{selectedBoard.is_owner ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1.5"></circle>
|
||||
<circle cx="19" cy="12" r="1.5"></circle>
|
||||
<circle cx="5" cy="12" r="1.5"></circle>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
)}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1.5"></circle>
|
||||
<circle cx="19" cy="12" r="1.5"></circle>
|
||||
<circle cx="5" cy="12" r="1.5"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -77,13 +79,13 @@ function BoardSelector({
|
||||
|
||||
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
|
||||
<div className="dropdown-content">
|
||||
{boards.length === 0 ? (
|
||||
{activeBoards.length === 0 && archivedBoards.length === 0 ? (
|
||||
<div className="dropdown-empty">
|
||||
Нет досок
|
||||
</div>
|
||||
) : (
|
||||
<div className="dropdown-list">
|
||||
{boards.map(board => (
|
||||
{activeBoards.map(board => (
|
||||
<button
|
||||
key={board.id}
|
||||
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
|
||||
@@ -107,30 +109,37 @@ function BoardSelector({
|
||||
<span>Создать доску</span>
|
||||
</button>
|
||||
|
||||
{selectedBoard && showBoardAction && (
|
||||
<button
|
||||
className="dropdown-item board-action-item"
|
||||
onClick={(e) => { setIsOpen(false); onBoardEdit() }}
|
||||
>
|
||||
{selectedBoard.is_owner ? (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Настройки доски</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
<span>Покинуть доску</span>
|
||||
</>
|
||||
{archivedBoards.length > 0 && (
|
||||
<div className="archive-section">
|
||||
<button
|
||||
className="dropdown-item archive-toggle"
|
||||
onClick={() => setArchiveExpanded(!archiveExpanded)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
<span>Архив</span>
|
||||
<span className="archive-toggle-icon">
|
||||
{archiveExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{archiveExpanded && (
|
||||
<div className="archive-list">
|
||||
{archivedBoards.map(board => (
|
||||
<button
|
||||
key={board.id}
|
||||
className={`dropdown-item archive-item ${board.id === selectedBoardId ? 'selected' : ''}`}
|
||||
onClick={() => handleSelectBoard(board)}
|
||||
>
|
||||
<span className="item-name archive-item-name">{board.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,308 +1,439 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import BoardMembers from './BoardMembers'
|
||||
import Toast from './Toast'
|
||||
import DeleteButton from './DeleteButton'
|
||||
import './Buttons.css'
|
||||
import './BoardForm.css'
|
||||
|
||||
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [inviteEnabled, setInviteEnabled] = useState(false)
|
||||
const [inviteURL, setInviteURL] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingBoard, setLoadingBoard] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
const isEdit = !!boardId
|
||||
|
||||
useEffect(() => {
|
||||
if (boardId) {
|
||||
fetchBoard()
|
||||
}
|
||||
}, [boardId])
|
||||
|
||||
const fetchBoard = async () => {
|
||||
setLoadingBoard(true)
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setName(data.name)
|
||||
setInviteEnabled(data.invite_enabled)
|
||||
setInviteURL(data.invite_url || '')
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
||||
} finally {
|
||||
setLoadingBoard(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = boardId
|
||||
? `/api/shopping/boards/${boardId}`
|
||||
: '/api/shopping/boards'
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: boardId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
invite_enabled: inviteEnabled
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.invite_url) {
|
||||
setInviteURL(data.invite_url)
|
||||
}
|
||||
onSaved?.()
|
||||
if (!boardId) {
|
||||
onNavigate('shopping', { boardId: data.id })
|
||||
} else {
|
||||
onNavigate('shopping', { boardId: boardId })
|
||||
}
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateInviteLink = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInviteURL(data.invite_url)
|
||||
setInviteEnabled(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error generating invite link:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(inviteURL)
|
||||
setCopied(true)
|
||||
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleToggleInvite = async (enabled) => {
|
||||
setInviteEnabled(enabled)
|
||||
|
||||
if (boardId && enabled && !inviteURL) {
|
||||
await generateInviteLink()
|
||||
} else if (boardId) {
|
||||
try {
|
||||
await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invite_enabled: enabled })
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error updating invite status:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('shopping', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
if (loadingBoard) {
|
||||
return (
|
||||
<div className="board-form">
|
||||
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||
<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>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="board-form">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
||||
|
||||
<div className="form-card">
|
||||
<div className="form-group">
|
||||
<label htmlFor="board-name">Название</label>
|
||||
<input
|
||||
id="board-name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Название доски"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<h3>Доступ по ссылке</h3>
|
||||
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteEnabled}
|
||||
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>
|
||||
|
||||
{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
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import BoardMembers from './BoardMembers'
|
||||
import Toast from './Toast'
|
||||
import DeleteButton from './DeleteButton'
|
||||
import './Buttons.css'
|
||||
import './BoardForm.css'
|
||||
|
||||
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [inviteEnabled, setInviteEnabled] = useState(false)
|
||||
const [inviteURL, setInviteURL] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingBoard, setLoadingBoard] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
const [isOwner, setIsOwner] = useState(true)
|
||||
const [isArchived, setIsArchived] = useState(false)
|
||||
|
||||
const isEdit = !!boardId
|
||||
|
||||
useEffect(() => {
|
||||
if (boardId) {
|
||||
fetchBoard()
|
||||
}
|
||||
}, [boardId])
|
||||
|
||||
const fetchBoard = async () => {
|
||||
setLoadingBoard(true)
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setName(data.name)
|
||||
setInviteEnabled(data.invite_enabled)
|
||||
setInviteURL(data.invite_url || '')
|
||||
setIsOwner(data.is_owner)
|
||||
setIsArchived(data.is_archived || false)
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
||||
} finally {
|
||||
setLoadingBoard(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = boardId
|
||||
? `/api/shopping/boards/${boardId}`
|
||||
: '/api/shopping/boards'
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: boardId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
invite_enabled: inviteEnabled
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.invite_url) {
|
||||
setInviteURL(data.invite_url)
|
||||
}
|
||||
onSaved?.()
|
||||
if (!boardId) {
|
||||
onNavigate('shopping', { boardId: data.id })
|
||||
} else {
|
||||
onNavigate('shopping', { boardId: boardId })
|
||||
}
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateInviteLink = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInviteURL(data.invite_url)
|
||||
setInviteEnabled(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error generating invite link:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(inviteURL)
|
||||
setCopied(true)
|
||||
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleToggleInvite = async (enabled) => {
|
||||
setInviteEnabled(enabled)
|
||||
|
||||
if (boardId && enabled && !inviteURL) {
|
||||
await generateInviteLink()
|
||||
} else if (boardId) {
|
||||
try {
|
||||
await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invite_enabled: enabled })
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error updating invite status:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('shopping', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
|
||||
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}/leave`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('shopping', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
|
||||
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}/archive`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
onSaved?.()
|
||||
onNavigate('shopping', { boardDeleted: true })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/shopping/boards/${boardId}/unarchive`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsArchived(false)
|
||||
onSaved?.()
|
||||
setToastMessage({ text: 'Доска разархивирована', type: 'success' })
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
if (loadingBoard) {
|
||||
return (
|
||||
<div className="board-form">
|
||||
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||
<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>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</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 (
|
||||
<div className="board-form">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
||||
|
||||
<div className="form-card">
|
||||
<div className="form-group">
|
||||
<label htmlFor="board-name">Название</label>
|
||||
<input
|
||||
id="board-name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Название доски"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<h3>Доступ по ссылке</h3>
|
||||
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteEnabled}
|
||||
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)
|
||||
}
|
||||
|
||||
const handleBoardEdit = () => {
|
||||
if (selectedBoardId) {
|
||||
const board = boards.find(b => b.id === selectedBoardId)
|
||||
if (board?.is_owner) {
|
||||
onNavigate('shopping-board-form', { boardId: selectedBoardId })
|
||||
} else {
|
||||
if (window.confirm('Покинуть доску?')) {
|
||||
authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, { method: 'POST' })
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
fetchBoards()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleBoardEdit = (boardId) => {
|
||||
const id = boardId || selectedBoardId
|
||||
if (id) {
|
||||
onNavigate('shopping-board-form', { boardId: id })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,20 +148,20 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
setBoards(data || [])
|
||||
saveBoardsToCache(data || [])
|
||||
|
||||
const firstActive = data?.find(b => !b.is_archived) || (data?.length > 0 ? data[0] : null)
|
||||
// Проверяем, что выбранная доска существует в списке
|
||||
if (selectedBoardId) {
|
||||
const boardExists = data?.some(b => b.id === selectedBoardId)
|
||||
if (!boardExists && data?.length > 0) {
|
||||
// Сохранённая доска не существует, выбираем первую
|
||||
setSelectedBoardId(data[0].id)
|
||||
if (!boardExists && firstActive) {
|
||||
setSelectedBoardId(firstActive.id)
|
||||
}
|
||||
} else if (data?.length > 0) {
|
||||
} else if (firstActive) {
|
||||
// Пытаемся восстановить из localStorage
|
||||
const savedBoardId = getSavedBoardId()
|
||||
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
|
||||
setSelectedBoardId(savedBoardId)
|
||||
} else {
|
||||
setSelectedBoardId(data[0].id)
|
||||
setSelectedBoardId(firstActive.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,12 +377,13 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
}
|
||||
}, [boardDeleted])
|
||||
|
||||
// Если текущая доска больше не существует в списке - выбираем первую
|
||||
// Если текущая доска больше не существует в списке - выбираем первую неархивную
|
||||
useEffect(() => {
|
||||
if (boards.length > 0 && selectedBoardId) {
|
||||
const boardExists = boards.some(b => b.id === selectedBoardId)
|
||||
if (!boardExists) {
|
||||
setSelectedBoardId(boards[0].id)
|
||||
const firstActive = boards.find(b => !b.is_archived) || boards[0]
|
||||
setSelectedBoardId(firstActive.id)
|
||||
}
|
||||
}
|
||||
}, [boards, selectedBoardId])
|
||||
@@ -391,41 +392,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
setSelectedBoardId(boardId)
|
||||
}
|
||||
|
||||
const handleBoardEdit = () => {
|
||||
const board = boards.find(b => b.id === selectedBoardId)
|
||||
if (board?.is_owner) {
|
||||
onNavigate?.('board-form', { boardId: selectedBoardId })
|
||||
} else {
|
||||
// Показать подтверждение выхода
|
||||
handleLeaveBoard()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveBoard = async () => {
|
||||
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Убираем доску из списка
|
||||
const newBoards = boards.filter(b => b.id !== selectedBoardId)
|
||||
setBoards(newBoards)
|
||||
saveBoardsToCache(newBoards)
|
||||
|
||||
// Выбираем первую доску
|
||||
if (newBoards.length > 0) {
|
||||
setSelectedBoardId(newBoards[0].id)
|
||||
} else {
|
||||
setSelectedBoardId(null)
|
||||
setItems([])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error leaving board:', err)
|
||||
}
|
||||
const handleBoardEdit = (boardId) => {
|
||||
onNavigate?.('board-form', { boardId: boardId || selectedBoardId })
|
||||
}
|
||||
|
||||
const handleAddBoard = () => {
|
||||
@@ -712,6 +680,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
onBoardChange={handleBoardChange}
|
||||
onBoardEdit={handleBoardEdit}
|
||||
onAddBoard={handleAddBoard}
|
||||
archivedApiUrl="/api/wishlist/boards/archived"
|
||||
onBoardUnarchived={() => fetchBoards()}
|
||||
loading={boardsLoading}
|
||||
showBoardAction={false}
|
||||
/>
|
||||
|
||||
5
run.sh
5
run.sh
@@ -46,9 +46,10 @@ if docker-compose ps | grep -q "Up"; then
|
||||
echo " - Backend сервер (с пересборкой)"
|
||||
echo " - Frontend приложение (с пересборкой)"
|
||||
echo " - База данных"
|
||||
# Пересобираем и перезапускаем (BuildKit надёжно отслеживает изменения файлов)
|
||||
# Пересобираем без кэша и перезапускаем
|
||||
echo -e "${BLUE}Пересборка и перезапуск сервисов...${NC}"
|
||||
docker-compose up -d --build --force-recreate play-life-web backend
|
||||
docker-compose build --no-cache play-life-web backend
|
||||
docker-compose up -d --force-recreate play-life-web backend
|
||||
# Перезапускаем базу данных
|
||||
docker-compose restart db
|
||||
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
|
||||
|
||||
Reference in New Issue
Block a user