v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s

This commit is contained in:
poignatov
2026-01-12 17:42:51 +03:00
parent 3cf3cd4edb
commit 705eb2400e
9 changed files with 509 additions and 162 deletions

View File

@@ -1 +1 @@
3.9.4 3.9.5

View File

@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"log" "log"
"math" "math"
@@ -317,9 +318,10 @@ type UnlockConditionRequest struct {
} }
type WishlistResponse struct { 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 {
completed = append(completed, item) completedCount++
if includeCompleted {
completed = append(completed, item)
}
} else if item.Unlocked { } else if item.Unlocked {
unlocked = append(unlocked, item) unlocked = append(unlocked, item)
} else { } else {
@@ -9074,9 +9083,10 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
}) })
response := WishlistResponse{ response := WishlistResponse{
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,19 +9734,66 @@ 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>`)
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 { jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1)
priceStr := strings.ReplaceAll(matches[1], ",", ".") for _, match := range jsonLdMatches {
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { if len(match) > 1 {
metadata.Price = &price 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)<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
}
} }
} }
@@ -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)

View File

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

View File

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

View File

@@ -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) { setLoading(true)
setCompletedLoading(true)
} else {
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}${period}` conditionText = `${points} в ${project}${dateText}`
} }
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,28 +430,32 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
</div> </div>
)} )}
{/* Завершённые */} {/* Завершённые - показываем только если есть завершённые желания */}
<div className="section-divider"> {completedCount > 0 && (
<button
className="completed-toggle"
onClick={handleToggleCompleted}
>
<span className="completed-toggle-icon">
{completedExpanded ? '▼' : '▶'}
</span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<> <>
{completedLoading ? ( <div className="section-divider">
<div className="loading-completed"> <button
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div> className="completed-toggle"
</div> onClick={handleToggleCompleted}
) : ( >
<div className="wishlist-grid"> <span className="completed-toggle-icon">
{completed.map(renderItem)} {completedExpanded ? '▼' : '▶'}
</div> </span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<>
{completedLoading ? (
<div className="loading-completed">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="wishlist-grid">
{completed.map(renderItem)}
</div>
)}
</>
)} )}
</> </>
)} )}
@@ -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}>
Завершить Завершить

View File

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

View File

@@ -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 && (() => {
<div className="wishlist-detail-link"> try {
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer"> const url = new URL(wishlistItem.link)
Открыть ссылку const host = url.host.replace(/^www\./, '') // Убираем www. если есть
</a> return (
</div> <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">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
Открыть ссылку
</a>
</div>
)
}
})()}
{/* Условия разблокировки */} {/* Условия разблокировки */}
{renderUnlockConditions()} {renderUnlockConditions()}

View File

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

View File

@@ -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) => {
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) 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>