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

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

View File

@@ -1 +1 @@
6.22.0
6.23.0

View File

@@ -576,6 +576,7 @@ type WishlistBoard struct {
InviteURL *string `json:"invite_url,omitempty"`
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" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,15 +17,17 @@ function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
const [isDeleting, setIsDeleting] = useState(false)
const [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

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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 })
}
}

View File

@@ -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
View File

@@ -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}"