v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
@@ -320,6 +321,7 @@ type WishlistResponse struct {
|
|||||||
Unlocked []WishlistItem `json:"unlocked"`
|
Unlocked []WishlistItem `json:"unlocked"`
|
||||||
Locked []WishlistItem `json:"locked"`
|
Locked []WishlistItem `json:"locked"`
|
||||||
Completed []WishlistItem `json:"completed,omitempty"`
|
Completed []WishlistItem `json:"completed,omitempty"`
|
||||||
|
CompletedCount int `json:"completed_count"` // Количество завершённых желаний
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -8480,6 +8482,7 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
|
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
|
||||||
|
// Считает напрямую из таблицы nodes, фильтруя по дате entries
|
||||||
func (a *App) calculateProjectPointsFromDate(
|
func (a *App) calculateProjectPointsFromDate(
|
||||||
projectID int,
|
projectID int,
|
||||||
startDate sql.NullTime,
|
startDate sql.NullTime,
|
||||||
@@ -8488,39 +8491,32 @@ func (a *App) calculateProjectPointsFromDate(
|
|||||||
var totalScore float64
|
var totalScore float64
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Обновляем materialized view перед запросом
|
|
||||||
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Failed to refresh materialized view: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !startDate.Valid {
|
if !startDate.Valid {
|
||||||
// За всё время
|
// За всё время - считаем все nodes этого пользователя для указанного проекта
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
SELECT COALESCE(SUM(n.score), 0)
|
||||||
FROM weekly_report_mv wr
|
FROM nodes n
|
||||||
JOIN projects p ON wr.project_id = p.id
|
JOIN projects p ON n.project_id = p.id
|
||||||
WHERE wr.project_id = $1 AND p.user_id = $2
|
WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2
|
||||||
`, projectID, userID).Scan(&totalScore)
|
`, projectID, userID).Scan(&totalScore)
|
||||||
} else {
|
} else {
|
||||||
// С указанной даты до текущего момента
|
// С указанной даты до текущего момента
|
||||||
// Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE
|
// Считаем все nodes этого пользователя, где дата entry >= startDate
|
||||||
// Используем сравнение (year, week) >= (startDate_year, startDate_week)
|
// Используем DATE() для сравнения только по дате (без времени)
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT COALESCE(SUM(wr.total_score), 0)
|
SELECT COALESCE(SUM(n.score), 0)
|
||||||
FROM weekly_report_mv wr
|
FROM nodes n
|
||||||
JOIN projects p ON wr.project_id = p.id
|
JOIN entries e ON n.entry_id = e.id
|
||||||
WHERE wr.project_id = $1
|
JOIN projects p ON n.project_id = p.id
|
||||||
|
WHERE n.project_id = $1
|
||||||
|
AND n.user_id = $2
|
||||||
AND p.user_id = $2
|
AND p.user_id = $2
|
||||||
AND (
|
AND DATE(e.created_date) >= DATE($3)
|
||||||
wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER
|
|
||||||
OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER
|
|
||||||
AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER)
|
|
||||||
)
|
|
||||||
`, projectID, userID, startDate.Time).Scan(&totalScore)
|
`, projectID, userID, startDate.Time).Scan(&totalScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Error calculating project points from date: %v", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8805,9 +8801,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
||||||
conditionMet = err == nil && totalScore >= requiredPoints
|
if err != nil {
|
||||||
if err == nil {
|
// Если ошибка при расчете, устанавливаем 0
|
||||||
|
zeroScore := 0.0
|
||||||
|
condition.CurrentPoints = &zeroScore
|
||||||
|
conditionMet = false
|
||||||
|
} else {
|
||||||
condition.CurrentPoints = &totalScore
|
condition.CurrentPoints = &totalScore
|
||||||
|
conditionMet = totalScore >= requiredPoints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8857,7 +8858,11 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
// Если ошибка при расчете, устанавливаем 0
|
||||||
|
zeroScore := 0.0
|
||||||
|
condition.CurrentPoints = &zeroScore
|
||||||
|
} else {
|
||||||
condition.CurrentPoints = &totalScore
|
condition.CurrentPoints = &totalScore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9025,10 +9030,14 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
unlocked := make([]WishlistItem, 0)
|
unlocked := make([]WishlistItem, 0)
|
||||||
locked := make([]WishlistItem, 0)
|
locked := make([]WishlistItem, 0)
|
||||||
completed := make([]WishlistItem, 0)
|
completed := make([]WishlistItem, 0)
|
||||||
|
completedCount := 0
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Completed {
|
if item.Completed {
|
||||||
|
completedCount++
|
||||||
|
if includeCompleted {
|
||||||
completed = append(completed, item)
|
completed = append(completed, item)
|
||||||
|
}
|
||||||
} else if item.Unlocked {
|
} else if item.Unlocked {
|
||||||
unlocked = append(unlocked, item)
|
unlocked = append(unlocked, item)
|
||||||
} else {
|
} else {
|
||||||
@@ -9077,6 +9086,7 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Unlocked: unlocked,
|
Unlocked: unlocked,
|
||||||
Locked: locked,
|
Locked: locked,
|
||||||
Completed: completed,
|
Completed: completed,
|
||||||
|
CompletedCount: completedCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -9457,8 +9467,10 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем как JPEG
|
// Генерируем уникальное имя файла
|
||||||
filename := fmt.Sprintf("%d.jpg", wishlistID)
|
randomBytes := make([]byte, 8)
|
||||||
|
rand.Read(randomBytes)
|
||||||
|
filename := fmt.Sprintf("%d_%x.jpg", wishlistID, randomBytes)
|
||||||
filepath := filepath.Join(uploadDir, filename)
|
filepath := filepath.Join(uploadDir, filename)
|
||||||
|
|
||||||
dst, err := os.Create(filepath)
|
dst, err := os.Create(filepath)
|
||||||
@@ -9477,7 +9489,7 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем путь в БД
|
// Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша)
|
||||||
imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
|
imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
UPDATE wishlist_items
|
UPDATE wishlist_items
|
||||||
@@ -9690,13 +9702,14 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
body := string(bodyBytes)
|
body := string(bodyBytes)
|
||||||
metadata := &LinkMetadataResponse{}
|
metadata := &LinkMetadataResponse{}
|
||||||
|
|
||||||
// Извлекаем Open Graph теги
|
// Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive)
|
||||||
ogTitleRe := regexp.MustCompile(`<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
|
// Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы
|
||||||
ogTitleRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
|
ogTitleRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`)
|
||||||
ogImageRe := regexp.MustCompile(`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`)
|
ogTitleRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`)
|
||||||
ogImageRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']`)
|
ogImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
|
||||||
ogDescRe := regexp.MustCompile(`<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']`)
|
ogImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`)
|
||||||
ogDescRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']`)
|
ogDescRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`)
|
||||||
|
ogDescRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`)
|
||||||
|
|
||||||
// og:title
|
// og:title
|
||||||
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
|
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
@@ -9721,21 +9734,68 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// Если нет og:title, пытаемся взять <title>
|
// Если нет og:title, пытаемся взять <title>
|
||||||
if metadata.Title == "" {
|
if metadata.Title == "" {
|
||||||
titleRe := regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
|
titleRe := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
|
||||||
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
|
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
metadata.Title = strings.TrimSpace(matches[1])
|
metadata.Title = strings.TrimSpace(matches[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если нет og:image, пытаемся найти другие meta теги для изображения
|
||||||
|
if metadata.Image == "" {
|
||||||
|
// Twitter Card image
|
||||||
|
twitterImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
|
||||||
|
if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
|
metadata.Image = strings.TrimSpace(matches[1])
|
||||||
|
} else {
|
||||||
|
twitterImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`)
|
||||||
|
if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
|
metadata.Image = strings.TrimSpace(matches[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
|
// Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
|
||||||
// Schema.org Product price
|
// Сначала ищем в JSON-LD (Schema.org)
|
||||||
priceRe := regexp.MustCompile(`"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
|
jsonLdRe := regexp.MustCompile(`(?i)<script[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)</script>`)
|
||||||
|
jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1)
|
||||||
|
for _, match := range jsonLdMatches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
jsonStr := match[1]
|
||||||
|
// Ищем цену в JSON-LD
|
||||||
|
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
|
||||||
|
if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 {
|
||||||
|
priceStr := strings.ReplaceAll(priceMatches[1], ",", ".")
|
||||||
|
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
|
||||||
|
metadata.Price = &price
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли в JSON-LD, ищем в обычном HTML
|
||||||
|
if metadata.Price == nil {
|
||||||
|
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
|
||||||
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
|
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
priceStr := strings.ReplaceAll(matches[1], ",", ".")
|
priceStr := strings.ReplaceAll(matches[1], ",", ".")
|
||||||
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
|
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
|
||||||
metadata.Price = &price
|
metadata.Price = &price
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также ищем цену в meta тегах (некоторые сайты используют это)
|
||||||
|
if metadata.Price == nil {
|
||||||
|
metaPriceRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`)
|
||||||
|
if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 {
|
||||||
|
priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".")
|
||||||
|
// Убираем валюту и лишние символы
|
||||||
|
priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "")
|
||||||
|
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
|
||||||
|
metadata.Price = &price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Нормализуем URL изображения (делаем абсолютным)
|
// Нормализуем URL изображения (делаем абсолютным)
|
||||||
if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
|
if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
|
||||||
@@ -9749,9 +9809,9 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Декодируем HTML entities
|
// Декодируем HTML entities (используем стандартную библиотеку)
|
||||||
metadata.Title = decodeHTMLEntities(metadata.Title)
|
metadata.Title = html.UnescapeString(metadata.Title)
|
||||||
metadata.Description = decodeHTMLEntities(metadata.Description)
|
metadata.Description = html.UnescapeString(metadata.Description)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(metadata)
|
json.NewEncoder(w).Encode(metadata)
|
||||||
|
|||||||
@@ -881,9 +881,10 @@ function AppContent() {
|
|||||||
{loadedTabs['wishlist-form'] && (
|
{loadedTabs['wishlist-form'] && (
|
||||||
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
||||||
<WishlistForm
|
<WishlistForm
|
||||||
key={tabParams.wishlistId || 'new'}
|
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}`}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
wishlistId={tabParams.wishlistId}
|
wishlistId={tabParams.wishlistId}
|
||||||
|
editConditionIndex={tabParams.editConditionIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-modal-edit,
|
.wishlist-modal-edit,
|
||||||
|
.wishlist-modal-copy,
|
||||||
.wishlist-modal-complete,
|
.wishlist-modal-complete,
|
||||||
.wishlist-modal-delete {
|
.wishlist-modal-delete {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -287,6 +288,16 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-copy {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-copy:hover {
|
||||||
|
background-color: #8e44ad;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.wishlist-modal-complete {
|
.wishlist-modal-complete {
|
||||||
background-color: #27ae60;
|
background-color: #27ae60;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -9,37 +9,52 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [completed, setCompleted] = useState([])
|
const [completed, setCompleted] = useState([])
|
||||||
|
const [completedCount, setCompletedCount] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [completedExpanded, setCompletedExpanded] = useState(false)
|
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||||
const [completedLoading, setCompletedLoading] = useState(false)
|
const [completedLoading, setCompletedLoading] = useState(false)
|
||||||
const [selectedItem, setSelectedItem] = useState(null)
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWishlist()
|
fetchWishlist()
|
||||||
|
loadTasksAndProjects()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadTasksAndProjects = async () => {
|
||||||
|
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 : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading tasks and projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем данные при изменении refreshTrigger
|
// Обновляем данные при изменении refreshTrigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshTrigger > 0) {
|
if (refreshTrigger > 0) {
|
||||||
fetchWishlist()
|
fetchWishlist()
|
||||||
// Если завершённые развёрнуты, обновляем и их
|
|
||||||
if (completedExpanded) {
|
|
||||||
fetchWishlist(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [refreshTrigger])
|
}, [refreshTrigger])
|
||||||
|
|
||||||
const fetchWishlist = async (includeCompleted = false) => {
|
const fetchWishlist = async () => {
|
||||||
try {
|
try {
|
||||||
if (includeCompleted) {
|
|
||||||
setCompletedLoading(true)
|
|
||||||
} else {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
}
|
|
||||||
|
|
||||||
const url = includeCompleted ? `${API_URL}?include_completed=true` : API_URL
|
const response = await authFetch(API_URL)
|
||||||
const response = await authFetch(url)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке желаний')
|
throw new Error('Ошибка при загрузке желаний')
|
||||||
@@ -49,18 +64,42 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
// Объединяем разблокированные и заблокированные в один список
|
// Объединяем разблокированные и заблокированные в один список
|
||||||
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
||||||
setItems(allItems)
|
setItems(allItems)
|
||||||
if (includeCompleted) {
|
const count = data.completed_count || 0
|
||||||
setCompleted(data.completed || [])
|
setCompletedCount(count)
|
||||||
|
|
||||||
|
// Загружаем завершённые сразу, если они есть
|
||||||
|
if (count > 0) {
|
||||||
|
fetchCompleted()
|
||||||
|
} else {
|
||||||
|
setCompleted([])
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setItems([])
|
setItems([])
|
||||||
if (includeCompleted) {
|
|
||||||
setCompleted([])
|
setCompleted([])
|
||||||
}
|
setCompletedCount(0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCompleted = async () => {
|
||||||
|
try {
|
||||||
|
setCompletedLoading(true)
|
||||||
|
const response = await authFetch(`${API_URL}?include_completed=true`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке завершённых желаний')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setCompleted(data.completed || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching completed items:', err)
|
||||||
|
setCompleted([])
|
||||||
|
} finally {
|
||||||
setCompletedLoading(false)
|
setCompletedLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,8 +107,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
const handleToggleCompleted = () => {
|
const handleToggleCompleted = () => {
|
||||||
const newExpanded = !completedExpanded
|
const newExpanded = !completedExpanded
|
||||||
setCompletedExpanded(newExpanded)
|
setCompletedExpanded(newExpanded)
|
||||||
if (newExpanded && completed.length === 0) {
|
if (newExpanded && completed.length === 0 && completedCount > 0) {
|
||||||
fetchWishlist(true)
|
fetchCompleted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +172,108 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
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, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(copyData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createResponse.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)
|
||||||
|
// Не прерываем процесс, просто логируем ошибку
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItem(null)
|
||||||
|
// Открываем экран редактирования нового желания
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedItem(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatPrice = (price) => {
|
const formatPrice = (price) => {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -142,12 +283,38 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderUnlockCondition = (item) => {
|
// Находит первое невыполненное условие
|
||||||
if (item.unlocked || item.completed) return null
|
const findFirstUnmetCondition = (item) => {
|
||||||
if (!item.first_locked_condition) return null
|
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const condition = item.first_locked_condition
|
for (const condition of item.unlock_conditions) {
|
||||||
const moreCount = item.more_locked_conditions || 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMet) {
|
||||||
|
return condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderUnlockCondition = (item) => {
|
||||||
|
if (item.completed) return null
|
||||||
|
|
||||||
|
const condition = findFirstUnmetCondition(item)
|
||||||
|
if (!condition) return null
|
||||||
|
|
||||||
let conditionText = ''
|
let conditionText = ''
|
||||||
if (condition.type === 'task_completion') {
|
if (condition.type === 'task_completion') {
|
||||||
@@ -155,16 +322,14 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
} else {
|
} else {
|
||||||
const points = condition.required_points || 0
|
const points = condition.required_points || 0
|
||||||
const project = condition.project_name || 'Проект'
|
const project = condition.project_name || 'Проект'
|
||||||
let period = ''
|
let dateText = ''
|
||||||
if (condition.period_type) {
|
if (condition.start_date) {
|
||||||
const periodLabels = {
|
const date = new Date(condition.start_date + 'T00:00:00')
|
||||||
week: 'за неделю',
|
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
|
||||||
month: 'за месяц',
|
} else {
|
||||||
year: 'за год',
|
dateText = ' за всё время'
|
||||||
}
|
}
|
||||||
period = ' ' + periodLabels[condition.period_type] || ''
|
conditionText = `${points} в ${project}${dateText}`
|
||||||
}
|
|
||||||
conditionText = `${points} в ${project}${period}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,9 +341,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="condition-text">{conditionText}</span>
|
<span className="condition-text">{conditionText}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.price && (
|
|
||||||
<div className="card-price">{formatPrice(item.price)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -217,11 +379,18 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
|
|
||||||
<div className="card-name">{item.name}</div>
|
<div className="card-name">{item.name}</div>
|
||||||
|
|
||||||
{isFaded && !item.completed ? (
|
{(() => {
|
||||||
renderUnlockCondition(item)
|
// Показываем первое невыполненное условие, если есть
|
||||||
) : (
|
const unmetCondition = findFirstUnmetCondition(item)
|
||||||
item.price && <div className="card-price">{formatPrice(item.price)}</div>
|
if (unmetCondition && !item.completed) {
|
||||||
)}
|
return renderUnlockCondition(item)
|
||||||
|
}
|
||||||
|
// Если все условия выполнены или условий нет - показываем цену
|
||||||
|
if (item.price) {
|
||||||
|
return <div className="card-price">{formatPrice(item.price)}</div>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,7 +430,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Завершённые */}
|
{/* Завершённые - показываем только если есть завершённые желания */}
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<>
|
||||||
<div className="section-divider">
|
<div className="section-divider">
|
||||||
<button
|
<button
|
||||||
className="completed-toggle"
|
className="completed-toggle"
|
||||||
@@ -286,6 +457,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Модальное окно для действий */}
|
{/* Модальное окно для действий */}
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
@@ -298,6 +471,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
<button className="wishlist-modal-edit" onClick={handleEdit}>
|
<button className="wishlist-modal-edit" onClick={handleEdit}>
|
||||||
Редактировать
|
Редактировать
|
||||||
</button>
|
</button>
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleCopy}>
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
{!selectedItem.completed && selectedItem.unlocked && (
|
{!selectedItem.completed && selectedItem.unlocked && (
|
||||||
<button className="wishlist-modal-complete" onClick={handleComplete}>
|
<button className="wishlist-modal-complete" onClick={handleComplete}>
|
||||||
Завершить
|
Завершить
|
||||||
|
|||||||
@@ -34,13 +34,13 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
margin: 0 0 1.5rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-content {
|
.wishlist-detail-content {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1.5rem;
|
padding: 1rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
aspect-ratio: 5 / 6;
|
aspect-ratio: 5 / 6;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-link {
|
.wishlist-detail-link {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-link a {
|
.wishlist-detail-link a {
|
||||||
@@ -83,19 +83,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-conditions {
|
.wishlist-detail-conditions {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-section-title {
|
.wishlist-detail-section-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-condition {
|
.wishlist-detail-condition {
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-condition.met {
|
.wishlist-detail-condition.met {
|
||||||
@@ -110,7 +112,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.condition-icon {
|
.condition-icon {
|
||||||
@@ -122,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.condition-progress {
|
.condition-progress {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
margin-left: calc(16px + 0.5rem);
|
margin-left: calc(16px + 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +151,22 @@
|
|||||||
.progress-text {
|
.progress-text {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-remaining {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-actions {
|
.wishlist-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-edit-button,
|
.wishlist-detail-edit-button,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wishlist-detail-conditions">
|
<div className="wishlist-detail-conditions">
|
||||||
<h3 className="wishlist-detail-section-title">Условия разблокировки:</h3>
|
<h3 className="wishlist-detail-section-title">Цели:</h3>
|
||||||
{wishlistItem.unlock_conditions.map((condition, index) => {
|
{wishlistItem.unlock_conditions.map((condition, index) => {
|
||||||
let conditionText = ''
|
let conditionText = ''
|
||||||
let progress = null
|
let progress = null
|
||||||
@@ -174,19 +174,29 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
dateText = ' за всё время'
|
dateText = ' за всё время'
|
||||||
}
|
}
|
||||||
conditionText = `${requiredPoints} в ${project}${dateText}`
|
conditionText = `${requiredPoints} в ${project}${dateText}`
|
||||||
|
const remaining = Math.max(0, requiredPoints - currentPoints)
|
||||||
progress = {
|
progress = {
|
||||||
type: 'points',
|
type: 'points',
|
||||||
current: currentPoints,
|
current: currentPoints,
|
||||||
required: requiredPoints,
|
required: requiredPoints,
|
||||||
|
remaining: remaining,
|
||||||
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
|
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMet = wishlistItem.unlocked || (progress?.type === 'task' && progress.completed) ||
|
// Проверяем каждое условие индивидуально
|
||||||
(progress?.type === 'points' && progress.current >= progress.required)
|
let isMet = false
|
||||||
|
if (progress?.type === 'task') {
|
||||||
|
isMet = progress.completed === true
|
||||||
|
} else if (progress?.type === 'points') {
|
||||||
|
isMet = progress.current >= progress.required
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'}`}>
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'}`}
|
||||||
|
>
|
||||||
<div className="condition-header">
|
<div className="condition-header">
|
||||||
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
{isMet ? (
|
{isMet ? (
|
||||||
@@ -206,7 +216,10 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-text">
|
<div className="progress-text">
|
||||||
{Math.round(progress.current)} / {Math.round(progress.required)}
|
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
|
||||||
|
{progress.remaining > 0 && (
|
||||||
|
<span className="progress-remaining">Осталось: {Math.round(progress.remaining)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -259,13 +272,28 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ссылка */}
|
{/* Ссылка */}
|
||||||
{wishlistItem.link && (
|
{wishlistItem.link && (() => {
|
||||||
|
try {
|
||||||
|
const url = new URL(wishlistItem.link)
|
||||||
|
const host = url.host.replace(/^www\./, '') // Убираем www. если есть
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail-link">
|
||||||
|
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
{host}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Если URL некорректный, показываем оригинальный текст
|
||||||
|
return (
|
||||||
<div className="wishlist-detail-link">
|
<div className="wishlist-detail-link">
|
||||||
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
||||||
Открыть ссылку
|
Открыть ссылку
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Условия разблокировки */}
|
{/* Условия разблокировки */}
|
||||||
{renderUnlockConditions()}
|
{renderUnlockConditions()}
|
||||||
|
|||||||
@@ -204,6 +204,17 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-item-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-item-text:hover {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
.remove-condition-button {
|
.remove-condition-button {
|
||||||
background: #e74c3c;
|
background: #e74c3c;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const API_URL = '/api/wishlist'
|
|||||||
const TASKS_API_URL = '/api/tasks'
|
const TASKS_API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
|
|
||||||
function WishlistForm({ onNavigate, wishlistId }) {
|
function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
@@ -21,6 +21,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
|
||||||
const [unlockConditions, setUnlockConditions] = useState([])
|
const [unlockConditions, setUnlockConditions] = useState([])
|
||||||
const [showConditionForm, setShowConditionForm] = useState(false)
|
const [showConditionForm, setShowConditionForm] = useState(false)
|
||||||
|
const [editingConditionIndex, setEditingConditionIndex] = useState(null)
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -28,6 +29,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
||||||
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
// Загрузка задач и проектов
|
// Загрузка задач и проектов
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,6 +64,14 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
}
|
}
|
||||||
}, [wishlistId, tasks, projects])
|
}, [wishlistId, tasks, projects])
|
||||||
|
|
||||||
|
// Открываем форму редактирования условия, если передан editConditionIndex
|
||||||
|
useEffect(() => {
|
||||||
|
if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) {
|
||||||
|
setEditingConditionIndex(editConditionIndex)
|
||||||
|
setShowConditionForm(true)
|
||||||
|
}
|
||||||
|
}, [editConditionIndex, unlockConditions])
|
||||||
|
|
||||||
const loadWishlist = async () => {
|
const loadWishlist = async () => {
|
||||||
setLoadingWishlist(true)
|
setLoadingWishlist(true)
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +84,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
setPrice(data.price ? String(data.price) : '')
|
setPrice(data.price ? String(data.price) : '')
|
||||||
setLink(data.link || '')
|
setLink(data.link || '')
|
||||||
setImageUrl(data.image_url || null)
|
setImageUrl(data.image_url || null)
|
||||||
|
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
|
||||||
if (data.unlock_conditions) {
|
if (data.unlock_conditions) {
|
||||||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||||||
type: cond.type,
|
type: cond.type,
|
||||||
@@ -256,12 +267,32 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddCondition = () => {
|
const handleAddCondition = () => {
|
||||||
|
setEditingConditionIndex(null)
|
||||||
|
setShowConditionForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditCondition = (index) => {
|
||||||
|
setEditingConditionIndex(index)
|
||||||
setShowConditionForm(true)
|
setShowConditionForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConditionSubmit = (condition) => {
|
const handleConditionSubmit = (condition) => {
|
||||||
|
if (editingConditionIndex !== null) {
|
||||||
|
// Редактирование существующего условия
|
||||||
|
setUnlockConditions(prev => prev.map((cond, idx) =>
|
||||||
|
idx === editingConditionIndex ? { ...condition, display_order: idx } : cond
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
// Добавление нового условия
|
||||||
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
||||||
|
}
|
||||||
setShowConditionForm(false)
|
setShowConditionForm(false)
|
||||||
|
setEditingConditionIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConditionCancel = () => {
|
||||||
|
setShowConditionForm(false)
|
||||||
|
setEditingConditionIndex(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveCondition = (index) => {
|
const handleRemoveCondition = (index) => {
|
||||||
@@ -331,6 +362,12 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
|
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
setToastMessage({ text: 'Желание сохранено, но ошибка при загрузке картинки', type: 'warning' })
|
setToastMessage({ text: 'Желание сохранено, но ошибка при загрузке картинки', type: 'warning' })
|
||||||
|
} else {
|
||||||
|
// Обновляем imageUrl после успешной загрузки
|
||||||
|
const imageData = await imageResponse.json()
|
||||||
|
if (imageData.image_url) {
|
||||||
|
setImageUrl(imageData.image_url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +466,13 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
<label>Картинка</label>
|
<label>Картинка</label>
|
||||||
{imageUrl && !showCropper && (
|
{imageUrl && !showCropper && (
|
||||||
<div className="image-preview">
|
<div className="image-preview">
|
||||||
<img src={imageUrl} alt="Preview" />
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
title="Нажмите, чтобы изменить"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -442,14 +485,14 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!imageUrl && (
|
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageSelect}
|
onChange={handleImageSelect}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
style={{ display: imageUrl ? 'none' : 'block' }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCropper && (
|
{showCropper && (
|
||||||
@@ -495,7 +538,10 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
<div className="conditions-list">
|
<div className="conditions-list">
|
||||||
{unlockConditions.map((cond, idx) => (
|
{unlockConditions.map((cond, idx) => (
|
||||||
<div key={idx} className="condition-item">
|
<div key={idx} className="condition-item">
|
||||||
<span>
|
<span
|
||||||
|
className="condition-item-text"
|
||||||
|
onClick={() => handleEditCondition(idx)}
|
||||||
|
>
|
||||||
{cond.type === 'task_completion'
|
{cond.type === 'task_completion'
|
||||||
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
||||||
: `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}
|
: `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}
|
||||||
@@ -516,7 +562,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
onClick={handleAddCondition}
|
onClick={handleAddCondition}
|
||||||
className="add-condition-button"
|
className="add-condition-button"
|
||||||
>
|
>
|
||||||
Добавить условие
|
Добавить цель
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -534,7 +580,8 @@ function WishlistForm({ onNavigate, wishlistId }) {
|
|||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onSubmit={handleConditionSubmit}
|
onSubmit={handleConditionSubmit}
|
||||||
onCancel={() => setShowConditionForm(false)}
|
onCancel={handleConditionCancel}
|
||||||
|
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -627,13 +674,15 @@ function DateSelector({ value, onChange, placeholder = "За всё время"
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Компонент формы условия разблокировки
|
// Компонент формы цели
|
||||||
function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }) {
|
||||||
const [type, setType] = useState('project_points')
|
const [type, setType] = useState(editingCondition?.type || 'project_points')
|
||||||
const [taskId, setTaskId] = useState('')
|
const [taskId, setTaskId] = useState(editingCondition?.task_id?.toString() || '')
|
||||||
const [projectId, setProjectId] = useState('')
|
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
||||||
const [requiredPoints, setRequiredPoints] = useState('')
|
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
||||||
const [startDate, setStartDate] = useState('')
|
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
||||||
|
|
||||||
|
const isEditing = editingCondition !== null
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -666,7 +715,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
|||||||
return (
|
return (
|
||||||
<div className="condition-form-overlay" onClick={onCancel}>
|
<div className="condition-form-overlay" onClick={onCancel}>
|
||||||
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Добавить условие разблокировки</h3>
|
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Тип условия</label>
|
<label>Тип условия</label>
|
||||||
@@ -742,7 +791,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
|||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="submit-button">
|
<button type="submit" className="submit-button">
|
||||||
Добавить
|
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user