diff --git a/VERSION b/VERSION index 5c517bf..ecbc3b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.0 +4.16.0 diff --git a/nginx-unified.conf b/nginx-unified.conf index cc09d7c..138a2a4 100644 --- a/nginx-unified.conf +++ b/nginx-unified.conf @@ -62,8 +62,21 @@ server { proxy_cache_bypass $http_upgrade; } + # Proxy project endpoints to backend (must be before location /) + location ^~ /project/ { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + # Proxy other API endpoints to backend - location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|weekly_goals/setup)$ { + location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|weekly_goals/setup)$ { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 3493979..38f69d1 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -28,6 +28,8 @@ import ( "image/jpeg" + mathrand "math/rand" + "github.com/chromedp/chromedp" "github.com/disintegration/imaging" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -43,6 +45,42 @@ import ( "golang.org/x/crypto/bcrypt" ) +// Палитра из 30 контрастных цветов для проектов (HEX формат) +// Используется для генерации случайного цвета при создании проекта +// Должна быть синхронизирована с frontend (projectUtils.js) +var projectColorsPalette = []string{ + "#EF4444", // Красный + "#F97316", // Оранжевый + "#F59E0B", // Янтарный + "#EAB308", // Желтый + "#84CC16", // Лайм + "#22C55E", // Зеленый + "#10B981", // Изумрудный + "#14B8A6", // Бирюзовый + "#06B6D4", // Голубой + "#0EA5E9", // Небесный + "#3B82F6", // Синий + "#6366F1", // Индиго + "#8B5CF6", // Фиолетовый + "#A855F7", // Пурпурный + "#D946EF", // Фуксия + "#EC4899", // Розовый + "#F43F5E", // Розово-красный + "#DC2626", // Темно-красный + "#EA580C", // Темно-оранжевый + "#CA8A04", // Темно-желтый + "#65A30D", // Темно-лайм + "#16A34A", // Темно-зеленый + "#059669", // Темно-изумрудный + "#0D9488", // Темно-бирюзовый + "#0891B2", // Темно-голубой + "#0284C7", // Темно-небесный + "#2563EB", // Темно-синий + "#4F46E5", // Темно-индиго + "#7C3AED", // Темно-фиолетовый + "#9333EA", // Темно-пурпурный +} + type Word struct { ID int `json:"id"` Name string `json:"name"` @@ -113,6 +151,7 @@ type WeeklyProjectStats struct { Priority *int `json:"priority,omitempty"` CalculatedScore float64 `json:"calculated_score"` TodayChange *float64 `json:"today_change,omitempty"` + Color string `json:"color"` } type GroupsProgress struct { @@ -158,6 +197,7 @@ type Project struct { ProjectID int `json:"project_id"` ProjectName string `json:"project_name"` Priority *int `json:"priority,omitempty"` + Color string `json:"color"` } type ProjectPriorityUpdate struct { @@ -176,6 +216,7 @@ type FullStatisticsItem struct { TotalScore float64 `json:"total_score"` MinGoalScore float64 `json:"min_goal_score"` MaxGoalScore float64 `json:"max_goal_score"` + Color string `json:"color"` } type TodayEntryNode struct { @@ -377,9 +418,9 @@ type WishlistItem struct { LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"` - TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания - ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание - ProjectName *string `json:"project_name,omitempty"` // Название проекта + TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания + ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание + ProjectName *string `json:"project_name,omitempty"` // Название проекта } type UnlockConditionDisplay struct { @@ -729,6 +770,14 @@ func generateWebhookToken() (string, error) { return base64.URLEncoding.EncodeToString(b), nil } +// generateRandomProjectColor возвращает случайный цвет из предопределенной палитры +func generateRandomProjectColor() string { + if len(projectColorsPalette) == 0 { + return "#3B82F6" // Fallback цвет + } + return projectColorsPalette[mathrand.Intn(len(projectColorsPalette))] +} + func (a *App) generateAccessToken(userID int) (string, error) { claims := JWTClaims{ UserID: userID, @@ -2556,7 +2605,8 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, - wg.priority AS priority + wg.priority AS priority, + p.color FROM projects p LEFT JOIN @@ -2600,6 +2650,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { &minGoalScore, &maxGoalScore, &priority, + &project.Color, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) @@ -3091,7 +3142,8 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, - wg.priority AS priority + wg.priority AS priority, + p.color FROM projects p LEFT JOIN @@ -3252,7 +3304,8 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, - wg.priority AS priority + wg.priority AS priority, + p.color FROM projects p LEFT JOIN @@ -3293,6 +3346,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error &minGoalScore, &maxGoalScore, &priority, + &project.Color, ) if err != nil { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) @@ -3864,6 +3918,7 @@ func main() { // Note: /message/post, /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS") @@ -4656,10 +4711,11 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr if err == sql.ErrNoRows { // Проект не существует, создаем новый + randomColor := generateRandomProjectColor() _, err = tx.Exec(` - INSERT INTO projects (name, deleted, user_id) - VALUES ($1, FALSE, $2) - `, projectName, *userID) + INSERT INTO projects (name, deleted, user_id, color) + VALUES ($1, FALSE, $2, $3) + `, projectName, *userID, randomColor) if err != nil { // Если ошибка из-за уникальности, пробуем обновить существующий _, err = tx.Exec(` @@ -4685,10 +4741,11 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr if err == sql.ErrNoRows { // Проект не существует, создаем новый + randomColor := generateRandomProjectColor() _, err = tx.Exec(` - INSERT INTO projects (name, deleted) - VALUES ($1, FALSE) - `, projectName) + INSERT INTO projects (name, deleted, color) + VALUES ($1, FALSE, $2) + `, projectName, randomColor) if err != nil { return fmt.Errorf("failed to insert project %s: %w", projectName, err) } @@ -5211,7 +5268,8 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { SELECT id AS project_id, name AS project_name, - priority + priority, + color FROM projects WHERE @@ -5238,6 +5296,7 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { &project.ProjectID, &project.ProjectName, &priority, + &project.Color, ) if err != nil { log.Printf("Error scanning project row: %v", err) @@ -5433,6 +5492,69 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) }) } +type ProjectColorRequest struct { + ID int `json:"id"` + Color string `json:"color"` +} + +func (a *App) setProjectColorHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req ProjectColorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding project color request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ID == 0 { + sendErrorWithCORS(w, "id is required", http.StatusBadRequest) + return + } + + if req.Color == "" { + sendErrorWithCORS(w, "color is required", http.StatusBadRequest) + return + } + + // Проверяем, что цвет в правильном формате HEX + if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 { + sendErrorWithCORS(w, "color must be in HEX format (e.g., #FF5733)", http.StatusBadRequest) + return + } + + // Обновляем цвет проекта + _, err := a.DB.Exec(` + UPDATE projects + SET color = $1 + WHERE id = $2 AND user_id = $3 + `, req.Color, req.ID, userID) + + if err != nil { + log.Printf("Error updating project color: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating project color: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Project color updated successfully", + "id": req.ID, + "color": req.Color, + }) +} + type ProjectMoveRequest struct { ID int `json:"id"` NewName string `json:"new_name"` @@ -5720,12 +5842,13 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) { } // Создаем новый проект + randomColor := generateRandomProjectColor() var projectID int err = a.DB.QueryRow(` - INSERT INTO projects (name, deleted, user_id) - VALUES ($1, FALSE, $2) + INSERT INTO projects (name, deleted, user_id, color) + VALUES ($1, FALSE, $2, $3) RETURNING id - `, req.Name, userID).Scan(&projectID) + `, req.Name, userID, randomColor).Scan(&projectID) if err != nil { log.Printf("Error creating project: %v", err) @@ -6188,7 +6311,8 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { -- Максимальная цель: COALESCE(NULL, 0.0000) COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score, - p.id AS project_id + p.id AS project_id, + p.color FROM weekly_report_mv wr FULL OUTER JOIN @@ -6232,6 +6356,7 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { &item.MinGoalScore, &item.MaxGoalScore, &projectID, + &item.Color, ) if err != nil { log.Printf("Error scanning full statistics row: %v", err) @@ -6256,7 +6381,8 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { p.id AS project_id, p.name AS project_name, COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score, - COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score + COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score, + p.color FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER @@ -6288,7 +6414,8 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { var projectID int var projectName string var minGoalScore, maxGoalScore float64 - if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore); err == nil { + var projectColor string + if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore, &projectColor); err == nil { // Добавляем только если проекта еще нет в статистике if !existingProjects[projectID] { totalScore := 0.0 @@ -6304,6 +6431,7 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { TotalScore: totalScore, MinGoalScore: minGoalScore, MaxGoalScore: maxGoalScore, + Color: projectColor, } statistics = append(statistics, item) } diff --git a/play-life-backend/migrations/000011_add_color_to_projects.down.sql b/play-life-backend/migrations/000011_add_color_to_projects.down.sql new file mode 100644 index 0000000..52c341b --- /dev/null +++ b/play-life-backend/migrations/000011_add_color_to_projects.down.sql @@ -0,0 +1,9 @@ +-- Migration: Remove color field from projects table +-- Date: 2026-02-02 +-- +-- This migration removes the color field from projects table. + +DROP INDEX IF EXISTS idx_projects_color; + +ALTER TABLE projects +DROP COLUMN IF EXISTS color; diff --git a/play-life-backend/migrations/000011_add_color_to_projects.up.sql b/play-life-backend/migrations/000011_add_color_to_projects.up.sql new file mode 100644 index 0000000..2301b24 --- /dev/null +++ b/play-life-backend/migrations/000011_add_color_to_projects.up.sql @@ -0,0 +1,45 @@ +-- Migration: Add color field to projects table +-- Date: 2026-02-02 +-- +-- This migration adds color field to projects table to allow +-- custom color selection for projects. The field is NOT NULL, +-- and existing projects will be assigned colors from a predefined palette. + +-- Добавляем поле color +ALTER TABLE projects +ADD COLUMN color VARCHAR(7) NOT NULL DEFAULT '#3B82F6'; + +-- Палитра из 30 контрастных цветов (синхронизирована с backend и frontend) +-- Заполняем существующие проекты цветами из палитры +DO $$ +DECLARE + colors TEXT[] := ARRAY[ + '#EF4444', '#F97316', '#F59E0B', '#EAB308', '#84CC16', + '#22C55E', '#10B981', '#14B8A6', '#06B6D4', '#0EA5E9', + '#3B82F6', '#6366F1', '#8B5CF6', '#A855F7', '#D946EF', + '#EC4899', '#F43F5E', '#DC2626', '#EA580C', '#CA8A04', + '#65A30D', '#16A34A', '#059669', '#0D9488', '#0891B2', + '#0284C7', '#2563EB', '#4F46E5', '#7C3AED', '#9333EA' + ]; + project_record RECORD; + color_index INTEGER := 0; +BEGIN + -- Обновляем существующие проекты, присваивая им цвета из палитры + FOR project_record IN + SELECT id FROM projects ORDER BY id + LOOP + UPDATE projects + SET color = colors[1 + (color_index % array_length(colors, 1))] + WHERE id = project_record.id; + + color_index := color_index + 1; + END LOOP; +END $$; + +-- Убираем DEFAULT, так как теперь все проекты имеют цвет +ALTER TABLE projects +ALTER COLUMN color DROP DEFAULT; + +CREATE INDEX IF NOT EXISTS idx_projects_color ON projects(color); + +COMMENT ON COLUMN projects.color IS 'Project color in HEX format (e.g., #FF5733)'; diff --git a/play-life-web/nginx.conf b/play-life-web/nginx.conf index 9f10aaa..258de2e 100644 --- a/play-life-web/nginx.conf +++ b/play-life-web/nginx.conf @@ -36,8 +36,21 @@ server { proxy_cache_bypass $http_upgrade; } + # Proxy project endpoints to backend (must be before location /) + location ^~ /project/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + # Proxy other API endpoints to backend - location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|webhook/|weekly_goals/setup)$ { + location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup)$ { proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/play-life-web/package.json b/play-life-web/package.json index 8f5a2d4..5740cbe 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.15.0", + "version": "4.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/ColorPickerModal.jsx b/play-life-web/src/components/ColorPickerModal.jsx new file mode 100644 index 0000000..21a0037 --- /dev/null +++ b/play-life-web/src/components/ColorPickerModal.jsx @@ -0,0 +1,63 @@ +import { PROJECT_COLORS_PALETTE } from '../utils/projectUtils' +import './Integrations.css' + +function ColorPickerModal({ onClose, onColorSelect, currentColor }) { + const handleColorClick = (color) => { + onColorSelect(color) + onClose() + } + + return ( +