4.16.0: Добавлен выбор цвета для проектов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m9s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m9s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
Reference in New Issue
Block a user