diff --git a/VERSION b/VERSION
index e0d61b5..11aaa06 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.9.4
+3.9.5
diff --git a/play-life-backend/main.go b/play-life-backend/main.go
index c876f9f..f75795b 100644
--- a/play-life-backend/main.go
+++ b/play-life-backend/main.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
+ "html"
"io"
"log"
"math"
@@ -317,9 +318,10 @@ type UnlockConditionRequest struct {
}
type WishlistResponse struct {
- Unlocked []WishlistItem `json:"unlocked"`
- Locked []WishlistItem `json:"locked"`
- Completed []WishlistItem `json:"completed,omitempty"`
+ Unlocked []WishlistItem `json:"unlocked"`
+ Locked []WishlistItem `json:"locked"`
+ 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 считает баллы проекта с указанной даты до текущего момента
+// Считает напрямую из таблицы nodes, фильтруя по дате entries
func (a *App) calculateProjectPointsFromDate(
projectID int,
startDate sql.NullTime,
@@ -8488,39 +8491,32 @@ func (a *App) calculateProjectPointsFromDate(
var totalScore float64
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 {
- // За всё время
+ // За всё время - считаем все nodes этого пользователя для указанного проекта
err = a.DB.QueryRow(`
- SELECT COALESCE(SUM(wr.total_score), 0)
- FROM weekly_report_mv wr
- JOIN projects p ON wr.project_id = p.id
- WHERE wr.project_id = $1 AND p.user_id = $2
+ SELECT COALESCE(SUM(n.score), 0)
+ FROM nodes n
+ JOIN projects p ON n.project_id = p.id
+ WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2
`, projectID, userID).Scan(&totalScore)
} else {
// С указанной даты до текущего момента
- // Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE
- // Используем сравнение (year, week) >= (startDate_year, startDate_week)
+ // Считаем все nodes этого пользователя, где дата entry >= startDate
+ // Используем DATE() для сравнения только по дате (без времени)
err = a.DB.QueryRow(`
- SELECT COALESCE(SUM(wr.total_score), 0)
- FROM weekly_report_mv wr
- JOIN projects p ON wr.project_id = p.id
- WHERE wr.project_id = $1
+ SELECT COALESCE(SUM(n.score), 0)
+ FROM nodes n
+ JOIN entries e ON n.entry_id = e.id
+ 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 (
- 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)
- )
+ AND DATE(e.created_date) >= DATE($3)
`, projectID, userID, startDate.Time).Scan(&totalScore)
}
if err != nil {
+ log.Printf("Error calculating project points from date: %v", err)
return 0, err
}
@@ -8805,9 +8801,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
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
+ conditionMet = totalScore >= requiredPoints
}
}
}
@@ -8857,7 +8858,11 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
- if err == nil {
+ if err != nil {
+ // Если ошибка при расчете, устанавливаем 0
+ zeroScore := 0.0
+ condition.CurrentPoints = &zeroScore
+ } else {
condition.CurrentPoints = &totalScore
}
}
@@ -9025,10 +9030,14 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
unlocked := make([]WishlistItem, 0)
locked := make([]WishlistItem, 0)
completed := make([]WishlistItem, 0)
+ completedCount := 0
for _, item := range items {
if item.Completed {
- completed = append(completed, item)
+ completedCount++
+ if includeCompleted {
+ completed = append(completed, item)
+ }
} else if item.Unlocked {
unlocked = append(unlocked, item)
} else {
@@ -9074,9 +9083,10 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
})
response := WishlistResponse{
- Unlocked: unlocked,
- Locked: locked,
- Completed: completed,
+ Unlocked: unlocked,
+ Locked: locked,
+ Completed: completed,
+ CompletedCount: completedCount,
}
w.Header().Set("Content-Type", "application/json")
@@ -9457,8 +9467,10 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
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)
dst, err := os.Create(filepath)
@@ -9477,7 +9489,7 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
return
}
- // Обновляем путь в БД
+ // Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша)
imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
_, err = a.DB.Exec(`
UPDATE wishlist_items
@@ -9690,13 +9702,14 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
body := string(bodyBytes)
metadata := &LinkMetadataResponse{}
- // Извлекаем Open Graph теги
- ogTitleRe := regexp.MustCompile(` ]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
- ogTitleRe2 := regexp.MustCompile(` ]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
- ogImageRe := regexp.MustCompile(` ]+property=["']og:image["'][^>]+content=["']([^"']+)["']`)
- ogImageRe2 := regexp.MustCompile(` ]+content=["']([^"']+)["'][^>]+property=["']og:image["']`)
- ogDescRe := regexp.MustCompile(` ]+property=["']og:description["'][^>]+content=["']([^"']+)["']`)
- ogDescRe2 := regexp.MustCompile(` ]+content=["']([^"']+)["'][^>]+property=["']og:description["']`)
+ // Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive)
+ // Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы
+ ogTitleRe := regexp.MustCompile(`(?i) ]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`)
+ ogTitleRe2 := regexp.MustCompile(`(?i) ]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`)
+ ogImageRe := regexp.MustCompile(`(?i) ]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
+ ogImageRe2 := regexp.MustCompile(`(?i) ]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`)
+ ogDescRe := regexp.MustCompile(`(?i) ]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`)
+ ogDescRe2 := regexp.MustCompile(`(?i) ]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`)
// og:title
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
@@ -9721,19 +9734,66 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
// Если нет og:title, пытаемся взять
if metadata.Title == "" {
- titleRe := regexp.MustCompile(`]*>([^<]+) `)
+ titleRe := regexp.MustCompile(`(?i)]*>([^<]+) `)
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
}
}
+ // Если нет og:image, пытаемся найти другие meta теги для изображения
+ if metadata.Image == "" {
+ // Twitter Card image
+ twitterImageRe := regexp.MustCompile(`(?i) ]*(?: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) ]*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 Product price
- priceRe := regexp.MustCompile(`"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
- if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
- priceStr := strings.ReplaceAll(matches[1], ",", ".")
- if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
- metadata.Price = &price
+ // Сначала ищем в JSON-LD (Schema.org)
+ jsonLdRe := regexp.MustCompile(`(?i)`)
+ 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 {
+ priceStr := strings.ReplaceAll(matches[1], ",", ".")
+ if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
+ metadata.Price = &price
+ }
+ }
+ }
+
+ // Также ищем цену в meta тегах (некоторые сайты используют это)
+ if metadata.Price == nil {
+ metaPriceRe := regexp.MustCompile(`(?i) ]*(?: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
+ }
}
}
@@ -9749,9 +9809,9 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
}
}
- // Декодируем HTML entities
- metadata.Title = decodeHTMLEntities(metadata.Title)
- metadata.Description = decodeHTMLEntities(metadata.Description)
+ // Декодируем HTML entities (используем стандартную библиотеку)
+ metadata.Title = html.UnescapeString(metadata.Title)
+ metadata.Description = html.UnescapeString(metadata.Description)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx
index ff8dbac..c5b7f43 100644
--- a/play-life-web/src/App.jsx
+++ b/play-life-web/src/App.jsx
@@ -881,9 +881,10 @@ function AppContent() {
{loadedTabs['wishlist-form'] && (
)}
diff --git a/play-life-web/src/components/Wishlist.css b/play-life-web/src/components/Wishlist.css
index fdd8c92..ae5f795 100644
--- a/play-life-web/src/components/Wishlist.css
+++ b/play-life-web/src/components/Wishlist.css
@@ -265,6 +265,7 @@
}
.wishlist-modal-edit,
+.wishlist-modal-copy,
.wishlist-modal-complete,
.wishlist-modal-delete {
width: 100%;
@@ -287,6 +288,16 @@
transform: translateY(-1px);
}
+.wishlist-modal-copy {
+ background-color: #9b59b6;
+ color: white;
+}
+
+.wishlist-modal-copy:hover {
+ background-color: #8e44ad;
+ transform: translateY(-1px);
+}
+
.wishlist-modal-complete {
background-color: #27ae60;
color: white;
diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx
index 5a02958..ccf7259 100644
--- a/play-life-web/src/components/Wishlist.jsx
+++ b/play-life-web/src/components/Wishlist.jsx
@@ -9,37 +9,52 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
+ const [completedCount, setCompletedCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
+ const [tasks, setTasks] = useState([])
+ const [projects, setProjects] = useState([])
useEffect(() => {
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
useEffect(() => {
if (refreshTrigger > 0) {
fetchWishlist()
- // Если завершённые развёрнуты, обновляем и их
- if (completedExpanded) {
- fetchWishlist(true)
- }
}
}, [refreshTrigger])
- const fetchWishlist = async (includeCompleted = false) => {
+ const fetchWishlist = async () => {
try {
- if (includeCompleted) {
- setCompletedLoading(true)
- } else {
- setLoading(true)
- }
+ setLoading(true)
- const url = includeCompleted ? `${API_URL}?include_completed=true` : API_URL
- const response = await authFetch(url)
+ const response = await authFetch(API_URL)
if (!response.ok) {
throw new Error('Ошибка при загрузке желаний')
@@ -49,18 +64,42 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
// Объединяем разблокированные и заблокированные в один список
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
setItems(allItems)
- if (includeCompleted) {
- setCompleted(data.completed || [])
+ const count = data.completed_count || 0
+ setCompletedCount(count)
+
+ // Загружаем завершённые сразу, если они есть
+ if (count > 0) {
+ fetchCompleted()
+ } else {
+ setCompleted([])
}
+
setError('')
} catch (err) {
setError(err.message)
setItems([])
- if (includeCompleted) {
- setCompleted([])
- }
+ setCompleted([])
+ setCompletedCount(0)
} finally {
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)
}
}
@@ -68,8 +107,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
- if (newExpanded && completed.length === 0) {
- fetchWishlist(true)
+ if (newExpanded && completed.length === 0 && completedCount > 0) {
+ 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) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
@@ -142,12 +283,38 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}).format(price)
}
- const renderUnlockCondition = (item) => {
- if (item.unlocked || item.completed) return null
- if (!item.first_locked_condition) return null
+ // Находит первое невыполненное условие
+ const findFirstUnmetCondition = (item) => {
+ if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
+ return null
+ }
- const condition = item.first_locked_condition
- const moreCount = item.more_locked_conditions || 0
+ for (const condition of item.unlock_conditions) {
+ 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 = ''
if (condition.type === 'task_completion') {
@@ -155,16 +322,14 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
} else {
const points = condition.required_points || 0
const project = condition.project_name || 'Проект'
- let period = ''
- if (condition.period_type) {
- const periodLabels = {
- week: 'за неделю',
- month: 'за месяц',
- year: 'за год',
- }
- period = ' ' + periodLabels[condition.period_type] || ''
+ let dateText = ''
+ if (condition.start_date) {
+ const date = new Date(condition.start_date + 'T00:00:00')
+ dateText = ` с ${date.toLocaleDateString('ru-RU')}`
+ } else {
+ dateText = ' за всё время'
}
- conditionText = `${points} в ${project}${period}`
+ conditionText = `${points} в ${project}${dateText}`
}
return (
@@ -176,9 +341,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
{conditionText}
- {item.price && (
- {formatPrice(item.price)}
- )}
)
@@ -217,11 +379,18 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
{item.name}
- {isFaded && !item.completed ? (
- renderUnlockCondition(item)
- ) : (
- item.price && {formatPrice(item.price)}
- )}
+ {(() => {
+ // Показываем первое невыполненное условие, если есть
+ const unmetCondition = findFirstUnmetCondition(item)
+ if (unmetCondition && !item.completed) {
+ return renderUnlockCondition(item)
+ }
+ // Если все условия выполнены или условий нет - показываем цену
+ if (item.price) {
+ return {formatPrice(item.price)}
+ }
+ return null
+ })()}
)
}
@@ -261,28 +430,32 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
)}
- {/* Завершённые */}
-
-
-
- {completedExpanded ? '▼' : '▶'}
-
- Завершённые
-
-
- {completedExpanded && (
+ {/* Завершённые - показываем только если есть завершённые желания */}
+ {completedCount > 0 && (
<>
- {completedLoading ? (
-
- ) : (
-
- {completed.map(renderItem)}
-
+
+
+
+ {completedExpanded ? '▼' : '▶'}
+
+ Завершённые
+
+
+ {completedExpanded && (
+ <>
+ {completedLoading ? (
+
+ ) : (
+
+ {completed.map(renderItem)}
+
+ )}
+ >
)}
>
)}
@@ -298,6 +471,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
Редактировать
+
+ Копировать
+
{!selectedItem.completed && selectedItem.unlocked && (
Завершить
diff --git a/play-life-web/src/components/WishlistDetail.css b/play-life-web/src/components/WishlistDetail.css
index 4977bc6..737018d 100644
--- a/play-life-web/src/components/WishlistDetail.css
+++ b/play-life-web/src/components/WishlistDetail.css
@@ -34,13 +34,13 @@
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
- margin: 0 0 1.5rem 0;
+ margin: 0 0 0.75rem 0;
}
.wishlist-detail-content {
background: white;
border-radius: 0.5rem;
- padding: 1.5rem;
+ padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
@@ -49,7 +49,7 @@
aspect-ratio: 5 / 6;
border-radius: 12px;
overflow: hidden;
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
background: #f0f0f0;
}
@@ -63,11 +63,11 @@
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
}
.wishlist-detail-link {
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
}
.wishlist-detail-link a {
@@ -83,19 +83,21 @@
}
.wishlist-detail-conditions {
- margin-bottom: 1.5rem;
+ margin-bottom: 0.75rem;
}
.wishlist-detail-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
- margin: 0 0 0.75rem 0;
+ margin: 0 0 0.5rem 0;
}
.wishlist-detail-condition {
- padding: 0.75rem 0;
+ padding: 0.75rem;
font-size: 0.95rem;
+ border-radius: 8px;
+ margin-bottom: 0.5rem;
}
.wishlist-detail-condition.met {
@@ -110,7 +112,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
- margin-bottom: 0.5rem;
+ margin-bottom: 0.25rem;
}
.condition-icon {
@@ -122,7 +124,7 @@
}
.condition-progress {
- margin-top: 0.5rem;
+ margin-top: 0.25rem;
margin-left: calc(16px + 0.5rem);
}
@@ -149,13 +151,22 @@
.progress-text {
font-size: 0.85rem;
color: #666;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.progress-remaining {
+ color: #e74c3c;
+ font-weight: 500;
}
.wishlist-detail-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
- margin-top: 1.5rem;
+ margin-top: 0.75rem;
}
.wishlist-detail-edit-button,
diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx
index 148eba4..4ea2b33 100644
--- a/play-life-web/src/components/WishlistDetail.jsx
+++ b/play-life-web/src/components/WishlistDetail.jsx
@@ -150,7 +150,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
return (
-
Условия разблокировки:
+
Цели:
{wishlistItem.unlock_conditions.map((condition, index) => {
let conditionText = ''
let progress = null
@@ -174,19 +174,29 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
dateText = ' за всё время'
}
conditionText = `${requiredPoints} в ${project}${dateText}`
+ const remaining = Math.max(0, requiredPoints - currentPoints)
progress = {
type: 'points',
current: currentPoints,
required: requiredPoints,
+ remaining: remaining,
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 (
-
+
{isMet ? (
@@ -206,7 +216,10 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
>
- {Math.round(progress.current)} / {Math.round(progress.required)}
+ {Math.round(progress.current)} / {Math.round(progress.required)}
+ {progress.remaining > 0 && (
+ Осталось: {Math.round(progress.remaining)}
+ )}
)}
@@ -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 (
+
+ )
+ } catch {
+ // Если URL некорректный, показываем оригинальный текст
+ return (
+
+ )
+ }
+ })()}
{/* Условия разблокировки */}
{renderUnlockConditions()}
diff --git a/play-life-web/src/components/WishlistForm.css b/play-life-web/src/components/WishlistForm.css
index 06daae4..7e20407 100644
--- a/play-life-web/src/components/WishlistForm.css
+++ b/play-life-web/src/components/WishlistForm.css
@@ -204,6 +204,17 @@
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 {
background: #e74c3c;
color: white;
diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx
index 2017bb8..329ecb1 100644
--- a/play-life-web/src/components/WishlistForm.jsx
+++ b/play-life-web/src/components/WishlistForm.jsx
@@ -8,7 +8,7 @@ const API_URL = '/api/wishlist'
const TASKS_API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
-function WishlistForm({ onNavigate, wishlistId }) {
+function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [price, setPrice] = useState('')
@@ -21,6 +21,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
const [unlockConditions, setUnlockConditions] = useState([])
const [showConditionForm, setShowConditionForm] = useState(false)
+ const [editingConditionIndex, setEditingConditionIndex] = useState(null)
const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
@@ -28,6 +29,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
const [toastMessage, setToastMessage] = useState(null)
const [loadingWishlist, setLoadingWishlist] = useState(false)
const [fetchingMetadata, setFetchingMetadata] = useState(false)
+ const fileInputRef = useRef(null)
// Загрузка задач и проектов
useEffect(() => {
@@ -62,6 +64,14 @@ function WishlistForm({ onNavigate, wishlistId }) {
}
}, [wishlistId, tasks, projects])
+ // Открываем форму редактирования условия, если передан editConditionIndex
+ useEffect(() => {
+ if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) {
+ setEditingConditionIndex(editConditionIndex)
+ setShowConditionForm(true)
+ }
+ }, [editConditionIndex, unlockConditions])
+
const loadWishlist = async () => {
setLoadingWishlist(true)
try {
@@ -74,6 +84,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
setPrice(data.price ? String(data.price) : '')
setLink(data.link || '')
setImageUrl(data.image_url || null)
+ setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
type: cond.type,
@@ -256,12 +267,32 @@ function WishlistForm({ onNavigate, wishlistId }) {
}
const handleAddCondition = () => {
+ setEditingConditionIndex(null)
+ setShowConditionForm(true)
+ }
+
+ const handleEditCondition = (index) => {
+ setEditingConditionIndex(index)
setShowConditionForm(true)
}
const handleConditionSubmit = (condition) => {
- setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
+ if (editingConditionIndex !== null) {
+ // Редактирование существующего условия
+ setUnlockConditions(prev => prev.map((cond, idx) =>
+ idx === editingConditionIndex ? { ...condition, display_order: idx } : cond
+ ))
+ } else {
+ // Добавление нового условия
+ setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
+ }
setShowConditionForm(false)
+ setEditingConditionIndex(null)
+ }
+
+ const handleConditionCancel = () => {
+ setShowConditionForm(false)
+ setEditingConditionIndex(null)
}
const handleRemoveCondition = (index) => {
@@ -331,6 +362,12 @@ function WishlistForm({ onNavigate, wishlistId }) {
if (!imageResponse.ok) {
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 }) {
Картинка
{imageUrl && !showCropper && (
-
+
fileInputRef.current?.click()}
+ style={{ cursor: 'pointer' }}
+ title="Нажмите, чтобы изменить"
+ />
{
@@ -442,14 +485,14 @@ function WishlistForm({ onNavigate, wishlistId }) {
)}
- {!imageUrl && (
-
- )}
+
{showCropper && (
@@ -495,7 +538,10 @@ function WishlistForm({ onNavigate, wishlistId }) {
{unlockConditions.map((cond, idx) => (
-
+ handleEditCondition(idx)}
+ >
{cond.type === 'task_completion'
? `Задача: ${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')}` : ' за всё время'}`}
@@ -516,7 +562,7 @@ function WishlistForm({ onNavigate, wishlistId }) {
onClick={handleAddCondition}
className="add-condition-button"
>
- Добавить условие
+ Добавить цель
@@ -534,7 +580,8 @@ function WishlistForm({ onNavigate, wishlistId }) {
tasks={tasks}
projects={projects}
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 }) {
- const [type, setType] = useState('project_points')
- const [taskId, setTaskId] = useState('')
- const [projectId, setProjectId] = useState('')
- const [requiredPoints, setRequiredPoints] = useState('')
- const [startDate, setStartDate] = useState('')
+// Компонент формы цели
+function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }) {
+ const [type, setType] = useState(editingCondition?.type || 'project_points')
+ const [taskId, setTaskId] = useState(editingCondition?.task_id?.toString() || '')
+ const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
+ const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
+ const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
+
+ const isEditing = editingCondition !== null
const handleSubmit = (e) => {
e.preventDefault()
@@ -666,7 +715,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
return (
e.stopPropagation()}>
-
Добавить условие разблокировки
+
{isEditing ? 'Редактировать цель' : 'Добавить цель'}