diff --git a/VERSION b/VERSION
index afad818..92536a9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.11.0
+3.12.0
diff --git a/play-life-backend/main.go b/play-life-backend/main.go
index bbff993..b8773b8 100644
--- a/play-life-backend/main.go
+++ b/play-life-backend/main.go
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"fmt"
"html"
@@ -4055,6 +4056,7 @@ func main() {
// Wishlist
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
@@ -4062,6 +4064,7 @@ func main() {
protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS")
// Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
@@ -9527,7 +9530,7 @@ func (a *App) saveWishlistConditions(
return nil
}
-// getWishlistHandler возвращает список желаний
+// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых
func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
@@ -9542,16 +9545,15 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
return
}
- includeCompleted := r.URL.Query().Get("include_completed") == "true"
-
- items, err := a.getWishlistItemsWithConditions(userID, includeCompleted)
+ // Загружаем только незавершённые
+ items, err := a.getWishlistItemsWithConditions(userID, false)
if err != nil {
log.Printf("Error getting wishlist items: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
return
}
- // Получаем количество завершённых отдельным запросом (т.к. основной запрос может их не включать)
+ // Получаем количество завершённых
var completedCount int
err = a.DB.QueryRow(`
SELECT COUNT(*) FROM wishlist_items
@@ -9565,14 +9567,9 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
// Группируем и сортируем
unlocked := make([]WishlistItem, 0)
locked := make([]WishlistItem, 0)
- completed := make([]WishlistItem, 0)
for _, item := range items {
- if item.Completed {
- if includeCompleted {
- completed = append(completed, item)
- }
- } else if item.Unlocked {
+ if item.Unlocked {
unlocked = append(unlocked, item)
} else {
locked = append(locked, item)
@@ -9604,6 +9601,48 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
return priceI > priceJ
})
+ response := WishlistResponse{
+ Unlocked: unlocked,
+ Locked: locked,
+ CompletedCount: completedCount,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+}
+
+// getWishlistCompletedHandler возвращает список завершённых желаний
+func (a *App) getWishlistCompletedHandler(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
+ }
+
+ // Загружаем все желания включая завершённые
+ items, err := a.getWishlistItemsWithConditions(userID, true)
+ if err != nil {
+ log.Printf("Error getting completed wishlist items: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting completed wishlist items: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Фильтруем только завершённые
+ completed := make([]WishlistItem, 0)
+ for _, item := range items {
+ if item.Completed {
+ completed = append(completed, item)
+ }
+ }
+
+ // Сортируем по цене (дорогие → дешёвые)
sort.Slice(completed, func(i, j int) bool {
priceI := 0.0
priceJ := 0.0
@@ -9616,15 +9655,8 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
return priceI > priceJ
})
- response := WishlistResponse{
- Unlocked: unlocked,
- Locked: locked,
- Completed: completed,
- CompletedCount: completedCount,
- }
-
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(response)
+ json.NewEncoder(w).Encode(completed)
}
// createWishlistHandler создаёт новое желание
@@ -10183,6 +10215,246 @@ func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request)
})
}
+// copyWishlistHandler копирует желание
+func (a *App) copyWishlistHandler(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)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Получаем оригинальное желание
+ var name string
+ var price sql.NullFloat64
+ var link sql.NullString
+ var imagePath sql.NullString
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id, name, price, link, image_path
+ FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath)
+
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error getting wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Получаем условия оригинального желания
+ rows, err := a.DB.Query(`
+ SELECT
+ wc.display_order,
+ wc.task_condition_id,
+ wc.score_condition_id,
+ tc.task_id,
+ sc.project_id,
+ sc.required_points,
+ sc.start_date
+ FROM wishlist_conditions wc
+ LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
+ LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
+ WHERE wc.wishlist_item_id = $1
+ ORDER BY wc.display_order
+ `, itemID)
+ if err != nil {
+ log.Printf("Error getting wishlist conditions: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist conditions: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ var conditions []UnlockConditionRequest
+ for rows.Next() {
+ var displayOrder int
+ var taskConditionID, scoreConditionID sql.NullInt64
+ var taskID, projectID sql.NullInt64
+ var requiredPoints sql.NullFloat64
+ var startDate sql.NullString
+
+ err := rows.Scan(&displayOrder, &taskConditionID, &scoreConditionID, &taskID, &projectID, &requiredPoints, &startDate)
+ if err != nil {
+ log.Printf("Error scanning condition row: %v", err)
+ continue
+ }
+
+ cond := UnlockConditionRequest{
+ DisplayOrder: &displayOrder,
+ }
+
+ if taskConditionID.Valid && taskID.Valid {
+ cond.Type = "task_completion"
+ tid := int(taskID.Int64)
+ cond.TaskID = &tid
+ } else if scoreConditionID.Valid && projectID.Valid {
+ cond.Type = "project_points"
+ pid := int(projectID.Int64)
+ cond.ProjectID = &pid
+ if requiredPoints.Valid {
+ cond.RequiredPoints = &requiredPoints.Float64
+ }
+ if startDate.Valid {
+ cond.StartDate = &startDate.String
+ }
+ }
+
+ conditions = append(conditions, cond)
+ }
+
+ // Создаём копию в транзакции
+ tx, err := a.DB.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ // Создаём копию желания
+ var newWishlistID int
+ var priceVal, linkVal interface{}
+ if price.Valid {
+ priceVal = price.Float64
+ }
+ if link.Valid {
+ linkVal = link.String
+ }
+
+ err = tx.QueryRow(`
+ INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted)
+ VALUES ($1, $2, $3, $4, FALSE, FALSE)
+ RETURNING id
+ `, userID, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID)
+ if err != nil {
+ log.Printf("Error creating wishlist copy: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Сохраняем условия
+ if len(conditions) > 0 {
+ err = a.saveWishlistConditions(tx, newWishlistID, conditions)
+ if err != nil {
+ log.Printf("Error saving wishlist conditions: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ // Копируем изображение, если есть
+ if imagePath.Valid && imagePath.String != "" {
+ // Получаем путь к оригинальному файлу
+ uploadsDir := getEnv("UPLOADS_DIR", "/app/uploads")
+
+ // Очищаем путь от /uploads/ в начале и query параметров
+ cleanPath := imagePath.String
+ cleanPath = strings.TrimPrefix(cleanPath, "/uploads/")
+ if idx := strings.Index(cleanPath, "?"); idx != -1 {
+ cleanPath = cleanPath[:idx]
+ }
+
+ originalPath := filepath.Join(uploadsDir, cleanPath)
+
+ log.Printf("Copying image: imagePath=%s, cleanPath=%s, originalPath=%s", imagePath.String, cleanPath, originalPath)
+
+ // Проверяем, существует ли файл
+ if _, statErr := os.Stat(originalPath); statErr == nil {
+ // Создаём директорию для нового желания
+ newImageDir := filepath.Join(uploadsDir, "wishlist", strconv.Itoa(userID))
+ if mkdirErr := os.MkdirAll(newImageDir, 0755); mkdirErr != nil {
+ log.Printf("Error creating image dir: %v", mkdirErr)
+ }
+
+ // Генерируем уникальное имя файла
+ ext := filepath.Ext(cleanPath)
+ randomBytes := make([]byte, 8)
+ rand.Read(randomBytes)
+ newFileName := fmt.Sprintf("%d_%s%s", newWishlistID, hex.EncodeToString(randomBytes), ext)
+ newImagePath := filepath.Join(newImageDir, newFileName)
+
+ log.Printf("New image path: %s", newImagePath)
+
+ // Копируем файл
+ srcFile, openErr := os.Open(originalPath)
+ if openErr != nil {
+ log.Printf("Error opening source file: %v", openErr)
+ } else {
+ defer srcFile.Close()
+ dstFile, createErr := os.Create(newImagePath)
+ if createErr != nil {
+ log.Printf("Error creating dest file: %v", createErr)
+ } else {
+ defer dstFile.Close()
+ _, copyErr := io.Copy(dstFile, srcFile)
+ if copyErr != nil {
+ log.Printf("Error copying file: %v", copyErr)
+ } else {
+ // Обновляем путь к изображению в БД (с /uploads/ в начале для совместимости)
+ relativePath := "/uploads/" + filepath.Join("wishlist", strconv.Itoa(userID), newFileName)
+ log.Printf("Updating image_path in DB to: %s", relativePath)
+ _, updateErr := tx.Exec(`UPDATE wishlist_items SET image_path = $1 WHERE id = $2`, relativePath, newWishlistID)
+ if updateErr != nil {
+ log.Printf("Error updating image_path in DB: %v", updateErr)
+ }
+ }
+ }
+ }
+ } else {
+ log.Printf("Original image file not found: %s, error: %v", originalPath, statErr)
+ }
+ } else {
+ log.Printf("No image to copy: imagePath.Valid=%v, imagePath.String=%s", imagePath.Valid, imagePath.String)
+ }
+
+ if err := tx.Commit(); err != nil {
+ log.Printf("Error committing transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Получаем созданное желание с условиями
+ items, err := a.getWishlistItemsWithConditions(userID, false)
+ if err != nil {
+ log.Printf("Error getting created wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var createdItem *WishlistItem
+ for i := range items {
+ if items[i].ID == newWishlistID {
+ createdItem = &items[i]
+ break
+ }
+ }
+
+ if createdItem == nil {
+ sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(createdItem)
+}
+
// LinkMetadataResponse структура ответа с метаданными ссылки
type LinkMetadataResponse struct {
Title string `json:"title,omitempty"`
diff --git a/play-life-web/package.json b/play-life-web/package.json
index abe0cb3..a4d46e2 100644
--- a/play-life-web/package.json
+++ b/play-life-web/package.json
@@ -1,6 +1,6 @@
{
"name": "play-life-web",
- "version": "3.11.0",
+ "version": "3.12.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx
index 8140369..20f167c 100644
--- a/play-life-web/src/App.jsx
+++ b/play-life-web/src/App.jsx
@@ -615,9 +615,9 @@ function AppContent() {
{
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
- // task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
- const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
- const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined
+ // task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
+ const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
+ const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
setTabParams({})
if (isNewTabMain) {
@@ -848,6 +848,8 @@ function AppContent() {
onNavigate={handleNavigate}
taskId={tabParams.taskId}
wishlistId={tabParams.wishlistId}
+ returnTo={tabParams.returnTo}
+ returnWishlistId={tabParams.returnWishlistId}
/>
)}
@@ -857,6 +859,7 @@ function AppContent() {
)}
@@ -864,10 +867,11 @@ function AppContent() {
{loadedTabs['wishlist-form'] && (
)}
diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx
index a16d025..9b015b9 100644
--- a/play-life-web/src/components/TaskForm.jsx
+++ b/play-life-web/src/components/TaskForm.jsx
@@ -6,7 +6,7 @@ import './TaskForm.css'
const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
-function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
+function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('')
@@ -678,11 +678,28 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
throw new Error(errorMessage)
}
+ // Получаем сохранённую задачу из ответа
+ const savedTask = await response.json()
+ // Сервер возвращает Task напрямую, поэтому используем savedTask.id
+ const newTaskId = savedTask.id
+
+ console.log('[TaskForm] Task saved, returnTo:', returnTo, 'returnWishlistId:', returnWishlistId, 'newTaskId:', newTaskId)
+
// Очищаем форму после успешного сохранения
resetForm()
- // Возвращаемся к списку задач
- onNavigate?.('tasks')
+ // Если был returnTo, возвращаемся на форму желания с ID новой задачи
+ if (returnTo === 'wishlist-form') {
+ console.log('[TaskForm] Navigating back to wishlist-form with newTaskId:', newTaskId)
+ onNavigate?.(returnTo, {
+ wishlistId: returnWishlistId,
+ newTaskId: newTaskId,
+ })
+ } else {
+ console.log('[TaskForm] No returnTo, navigating to tasks')
+ // Стандартное поведение - возврат к списку задач
+ onNavigate?.('tasks')
+ }
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
console.error('Error saving task:', err)
diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx
index b6ceced..791cc28 100644
--- a/play-life-web/src/components/Wishlist.jsx
+++ b/play-life-web/src/components/Wishlist.jsx
@@ -1,11 +1,13 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Wishlist.css'
const API_URL = '/api/wishlist'
+const CACHE_KEY = 'wishlist_cache'
+const CACHE_COMPLETED_KEY = 'wishlist_completed_cache'
-function Wishlist({ onNavigate, refreshTrigger = 0 }) {
+function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
@@ -15,44 +17,84 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
- const [tasks, setTasks] = useState([])
- const [projects, setProjects] = useState([])
+ const fetchingRef = useRef(false)
+ const fetchingCompletedRef = useRef(false)
+ const initialFetchDoneRef = useRef(false)
+ const prevIsActiveRef = useRef(isActive)
- useEffect(() => {
- fetchWishlist()
- loadTasksAndProjects()
- }, [])
-
- const loadTasksAndProjects = async () => {
+ // Проверка наличия кэша
+ const hasCache = () => {
try {
- // Загружаем задачи
- const tasksResponse = await authFetch('/api/tasks')
- if (tasksResponse.ok) {
- const tasksData = await tasksResponse.json()
- setTasks(Array.isArray(tasksData) ? tasksData : [])
- }
-
- // Загружаем проекты
- const projectsResponse = await authFetch('/projects')
- if (projectsResponse.ok) {
- const projectsData = await projectsResponse.json()
- setProjects(Array.isArray(projectsData) ? projectsData : [])
- }
+ return localStorage.getItem(CACHE_KEY) !== null
} catch (err) {
- console.error('Error loading tasks and projects:', err)
+ return false
}
}
- // Обновляем данные при изменении refreshTrigger
- useEffect(() => {
- if (refreshTrigger > 0) {
- fetchWishlist()
- }
- }, [refreshTrigger])
-
- const fetchWishlist = async () => {
+ // Загрузка основных данных из кэша
+ const loadFromCache = () => {
try {
- setLoading(true)
+ const cached = localStorage.getItem(CACHE_KEY)
+ if (cached) {
+ const data = JSON.parse(cached)
+ setItems(data.items || [])
+ setCompletedCount(data.completedCount || 0)
+ return true
+ }
+ } catch (err) {
+ console.error('Error loading from cache:', err)
+ }
+ return false
+ }
+
+ // Загрузка завершённых из кэша
+ const loadCompletedFromCache = () => {
+ try {
+ const cached = localStorage.getItem(CACHE_COMPLETED_KEY)
+ if (cached) {
+ const data = JSON.parse(cached)
+ setCompleted(data || [])
+ return true
+ }
+ } catch (err) {
+ console.error('Error loading completed from cache:', err)
+ }
+ return false
+ }
+
+ // Сохранение основных данных в кэш
+ const saveToCache = (itemsData, count) => {
+ try {
+ localStorage.setItem(CACHE_KEY, JSON.stringify({
+ items: itemsData,
+ completedCount: count,
+ timestamp: Date.now()
+ }))
+ } catch (err) {
+ console.error('Error saving to cache:', err)
+ }
+ }
+
+ // Сохранение завершённых в кэш
+ const saveCompletedToCache = (completedData) => {
+ try {
+ localStorage.setItem(CACHE_COMPLETED_KEY, JSON.stringify(completedData))
+ } catch (err) {
+ console.error('Error saving completed to cache:', err)
+ }
+ }
+
+ // Загрузка основного списка
+ const fetchWishlist = async () => {
+ if (fetchingRef.current) return
+ fetchingRef.current = true
+
+ try {
+ const hasDataInState = items.length > 0 || completedCount > 0
+ const cacheExists = hasCache()
+ if (!hasDataInState && !cacheExists) {
+ setLoading(true)
+ }
const response = await authFetch(API_URL)
@@ -61,53 +103,125 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
const data = await response.json()
- // Объединяем разблокированные и заблокированные в один список
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
- setItems(allItems)
const count = data.completed_count || 0
+
+ setItems(allItems)
setCompletedCount(count)
-
- // Загружаем завершённые сразу, если они есть
- if (count > 0) {
- fetchCompleted()
- } else {
- setCompleted([])
- }
-
+ saveToCache(allItems, count)
setError('')
} catch (err) {
setError(err.message)
- setItems([])
- setCompleted([])
- setCompletedCount(0)
+ if (!hasCache()) {
+ setItems([])
+ setCompletedCount(0)
+ }
} finally {
setLoading(false)
+ fetchingRef.current = false
}
}
+ // Загрузка завершённых
const fetchCompleted = async () => {
+ if (fetchingCompletedRef.current) return
+ fetchingCompletedRef.current = true
+
try {
setCompletedLoading(true)
- const response = await authFetch(`${API_URL}?include_completed=true`)
+ const response = await authFetch(`${API_URL}/completed`)
if (!response.ok) {
throw new Error('Ошибка при загрузке завершённых желаний')
}
const data = await response.json()
- setCompleted(data.completed || [])
+ const completedData = Array.isArray(data) ? data : []
+ setCompleted(completedData)
+ saveCompletedToCache(completedData)
} catch (err) {
console.error('Error fetching completed items:', err)
setCompleted([])
} finally {
setCompletedLoading(false)
+ fetchingCompletedRef.current = false
}
}
+ // Первая инициализация
+ useEffect(() => {
+ if (!initialFetchDoneRef.current) {
+ initialFetchDoneRef.current = true
+
+ // Загружаем из кэша
+ const cacheLoaded = loadFromCache()
+ if (cacheLoaded) {
+ setLoading(false)
+ }
+
+ // Загружаем свежие данные
+ fetchWishlist()
+
+ // Если список завершённых раскрыт - загружаем их тоже
+ if (completedExpanded) {
+ loadCompletedFromCache()
+ fetchCompleted()
+ }
+ }
+ }, [])
+
+ // Обработка активации/деактивации таба
+ useEffect(() => {
+ const wasActive = prevIsActiveRef.current
+ prevIsActiveRef.current = isActive
+
+ // Пропускаем первую инициализацию (она обрабатывается отдельно)
+ if (!initialFetchDoneRef.current) return
+
+ // Когда таб становится видимым
+ if (isActive && !wasActive) {
+ // Показываем кэш, если есть данные
+ const hasDataInState = items.length > 0 || completedCount > 0
+ if (!hasDataInState) {
+ const cacheLoaded = loadFromCache()
+ if (cacheLoaded) {
+ setLoading(false)
+ } else {
+ setLoading(true)
+ }
+ }
+
+ // Всегда загружаем свежие данные основного списка
+ fetchWishlist()
+
+ // Если список завершённых раскрыт - загружаем их тоже
+ if (completedExpanded && completedCount > 0) {
+ fetchCompleted()
+ }
+ }
+ }, [isActive])
+
+ // Обновляем данные при изменении refreshTrigger
+ useEffect(() => {
+ if (refreshTrigger > 0) {
+ fetchWishlist()
+ if (completedExpanded && completedCount > 0) {
+ fetchCompleted()
+ }
+ }
+ }, [refreshTrigger])
+
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
- if (newExpanded && completed.length === 0 && completedCount > 0) {
+
+ // При раскрытии загружаем завершённые
+ if (newExpanded && completedCount > 0) {
+ // Показываем из кэша если есть
+ if (completed.length === 0) {
+ loadCompletedFromCache()
+ }
+ // Загружаем свежие данные
fetchCompleted()
}
}
@@ -145,7 +259,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
setSelectedItem(null)
- await fetchWishlist(completedExpanded)
+ await fetchWishlist()
+ if (completedExpanded) {
+ await fetchCompleted()
+ }
} catch (err) {
setError(err.message)
setSelectedItem(null)
@@ -165,7 +282,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
setSelectedItem(null)
- await fetchWishlist(completedExpanded)
+ await fetchWishlist()
+ if (completedExpanded) {
+ await fetchCompleted()
+ }
} catch (err) {
setError(err.message)
setSelectedItem(null)
@@ -176,97 +296,17 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
if (!selectedItem) return
try {
- // Загружаем полные данные желания
- const response = await authFetch(`${API_URL}/${selectedItem.id}`)
- if (!response.ok) {
- throw new Error('Ошибка при загрузке желания')
- }
-
- const itemData = await response.json()
-
- // Преобразуем условия из формата Display в формат Request
- const unlockConditions = (itemData.unlock_conditions || []).map((cond) => {
- const condition = {
- type: cond.type,
- display_order: cond.display_order,
- }
-
- if (cond.type === 'task_completion' && cond.task_name) {
- // Находим task_id по имени задачи
- const task = tasks.find(t => t.name === cond.task_name)
- if (task) {
- condition.task_id = task.id
- }
- } else if (cond.type === 'project_points' && cond.project_name) {
- // Находим project_id по имени проекта
- const project = projects.find(p => p.project_name === cond.project_name)
- if (project) {
- condition.project_id = project.project_id
- }
- if (cond.required_points !== undefined && cond.required_points !== null) {
- condition.required_points = cond.required_points
- }
- if (cond.start_date) {
- condition.start_date = cond.start_date
- }
- }
-
- return condition
- })
-
- // Создаем копию желания
- const copyData = {
- name: `${itemData.name} (копия)`,
- price: itemData.price || null,
- link: itemData.link || null,
- unlock_conditions: unlockConditions,
- }
-
- const createResponse = await authFetch(API_URL, {
+ const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(copyData),
})
- if (!createResponse.ok) {
- throw new Error('Ошибка при создании копии')
+ if (!response.ok) {
+ throw new Error('Ошибка при копировании')
}
- const newItem = await createResponse.json()
-
- // Копируем изображение, если оно есть
- if (itemData.image_url) {
- try {
- // Загружаем изображение по URL (используем authFetch для авторизованных запросов)
- const imageResponse = await authFetch(itemData.image_url)
- if (imageResponse.ok) {
- const blob = await imageResponse.blob()
- // Проверяем, что это изображение и размер не превышает 5MB
- if (blob.type.startsWith('image/') && blob.size <= 5 * 1024 * 1024) {
- // Загружаем изображение для нового желания
- const formData = new FormData()
- formData.append('image', blob, 'image.jpg')
-
- const uploadResponse = await authFetch(`${API_URL}/${newItem.id}/image`, {
- method: 'POST',
- body: formData,
- })
-
- if (!uploadResponse.ok) {
- console.error('Ошибка при копировании изображения')
- }
- }
- }
- } catch (imgErr) {
- console.error('Ошибка при копировании изображения:', imgErr)
- // Не прерываем процесс, просто логируем ошибку
- }
- }
+ const newItem = await response.json()
setSelectedItem(null)
- // Открываем экран редактирования нового желания
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
} catch (err) {
setError(err.message)
@@ -283,7 +323,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}).format(price)
}
- // Находит первое невыполненное условие
const findFirstUnmetCondition = (item) => {
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
return null
@@ -293,10 +332,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
let isMet = false
if (condition.type === 'task_completion') {
- // Условие выполнено, если task_completed === true
isMet = condition.task_completed === true
} else if (condition.type === 'project_points') {
- // Условие выполнено, если current_points >= required_points
const currentPoints = condition.current_points || 0
const requiredPoints = condition.required_points || 0
isMet = currentPoints >= requiredPoints
@@ -380,12 +417,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
{item.name}
{(() => {
- // Показываем первое невыполненное условие, если есть
const unmetCondition = findFirstUnmetCondition(item)
if (unmetCondition && !item.completed) {
return renderUnlockCondition(item)
}
- // Если все условия выполнены или условий нет - показываем цену
if (item.price) {
return