Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s

This commit is contained in:
poignatov
2026-01-13 18:22:02 +03:00
parent cfd9339e48
commit db3b2640a8
17 changed files with 1166 additions and 1278 deletions

View File

@@ -1 +1 @@
3.10.8 3.11.0

View File

@@ -72,19 +72,15 @@ type TestProgressRequest struct {
} }
type Config struct { type Config struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` WordsCount int `json:"words_count"`
WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"`
MaxCards *int `json:"max_cards,omitempty"`
TryMessage string `json:"try_message"`
} }
type ConfigRequest struct { type ConfigRequest struct {
Name string `json:"name"` WordsCount int `json:"words_count"`
WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"`
MaxCards *int `json:"max_cards,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"`
TryMessage string `json:"try_message"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
} }
type Dictionary struct { type Dictionary struct {
@@ -214,6 +210,7 @@ type Task struct {
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
ConfigID *int `json:"config_id,omitempty"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"` ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"` SubtasksCount int `json:"subtasks_count"`
@@ -234,9 +231,13 @@ type Subtask struct {
} }
type TaskDetail struct { type TaskDetail struct {
Task Task `json:"task"` Task Task `json:"task"`
Rewards []Reward `json:"rewards"` Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"` Subtasks []Subtask `json:"subtasks"`
// Test-specific fields (only present if task has config_id)
WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
} }
type RewardRequest struct { type RewardRequest struct {
@@ -262,6 +263,11 @@ type TaskRequest struct {
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
// Test-specific fields
IsTest bool `json:"is_test,omitempty"`
WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
} }
type CompleteTaskRequest struct { type CompleteTaskRequest struct {
@@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request)
return return
} }
// If config_id is provided, send webhook with try_message // Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
if req.ConfigID != nil { // The config_id is kept in the request for potential future use, but we no longer send messages here
configID := *req.ConfigID // to avoid duplicate messages (one from test completion, one from task completion).
// Use mutex to prevent duplicate webhook sends
a.webhookMutex.Lock()
lastTime, exists := a.lastWebhookTime[configID]
now := time.Now()
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
if shouldSend {
a.lastWebhookTime[configID] = now
}
a.webhookMutex.Unlock()
if !shouldSend {
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
} else {
var tryMessage sql.NullString
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
if err == nil && tryMessage.Valid && tryMessage.String != "" {
// Process message directly (backend always runs together with frontend)
_, err := a.processMessage(tryMessage.String, &userID)
if err != nil {
log.Printf("Error processing message: %v", err)
// Remove from map on error so it can be retried
a.webhookMutex.Lock()
delete(a.lastWebhookTime, configID)
a.webhookMutex.Unlock()
} else {
log.Printf("Message processed successfully for config_id %d", configID)
}
} else if err != nil && err != sql.ErrNoRows {
log.Printf("Error fetching config: %v", err)
} else if err == nil && (!tryMessage.Valid || tryMessage.String == "") {
log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID)
}
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
} }
query := ` query := `
SELECT id, name, words_count, max_cards, try_message SELECT id, words_count, max_cards
FROM configs FROM configs
WHERE user_id = $1 WHERE user_id = $1
ORDER BY id ORDER BY id
@@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
var maxCards sql.NullInt64 var maxCards sql.NullInt64
err := rows.Scan( err := rows.Scan(
&config.ID, &config.ID,
&config.Name,
&config.WordsCount, &config.WordsCount,
&maxCards, &maxCards,
&config.TryMessage,
) )
if err != nil { if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
@@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
// Get configs // Get configs
configsQuery := ` configsQuery := `
SELECT id, name, words_count, max_cards, try_message SELECT id, words_count, max_cards
FROM configs FROM configs
WHERE user_id = $1 WHERE user_id = $1
ORDER BY id ORDER BY id
@@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
var maxCards sql.NullInt64 var maxCards sql.NullInt64
err := configsRows.Scan( err := configsRows.Scan(
&config.ID, &config.ID,
&config.Name,
&config.WordsCount, &config.WordsCount,
&maxCards, &maxCards,
&config.TryMessage,
) )
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
return
}
if req.WordsCount <= 0 { if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
return return
@@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
var id int var id int
err = tx.QueryRow(` err = tx.QueryRow(`
INSERT INTO configs (name, words_count, max_cards, try_message, user_id) INSERT INTO configs (words_count, max_cards, user_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3)
RETURNING id RETURNING id
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id) `, req.WordsCount, req.MaxCards, userID).Scan(&id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
return
}
if req.WordsCount <= 0 { if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
return return
@@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
result, err := tx.Exec(` result, err := tx.Exec(`
UPDATE configs UPDATE configs
SET name = $1, words_count = $2, max_cards = $3, try_message = $4 SET words_count = $1, max_cards = $2
WHERE id = $5 WHERE id = $3
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID) `, req.WordsCount, req.MaxCards, configID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2840,6 +2796,12 @@ func (a *App) initAuthDB() error {
// Не возвращаем ошибку, чтобы приложение могло запуститься // Не возвращаем ошибку, чтобы приложение могло запуститься
} }
// Apply migration 022: Refactor configs to link with tasks
if err := a.applyMigration022(); err != nil {
log.Printf("Warning: Failed to apply migration 022: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Clean up expired refresh tokens (only those with expiration date set) // Clean up expired refresh tokens (only those with expiration date set)
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
@@ -3109,6 +3071,51 @@ func (a *App) applyMigration021() error {
return nil return nil
} }
// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql
func (a *App) applyMigration022() error {
log.Printf("Applying migration 022: Refactor configs to link with tasks")
// Проверяем, существует ли уже поле config_id в tasks
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name = 'config_id'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check config_id column existence: %w", err)
}
if exists {
log.Printf("Migration 022 already applied (config_id column exists), skipping")
return nil
}
// Читаем SQL из файла миграции
migrationPath := "migrations/022_refactor_configs_to_tasks.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (в Docker)
migrationPath = "/migrations/022_refactor_configs_to_tasks.sql"
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 022: %w", err)
}
log.Printf("Migration 022 applied successfully")
return nil
}
func (a *App) initPlayLifeDB() error { func (a *App) initPlayLifeDB() error {
// Создаем таблицу projects // Создаем таблицу projects
createProjectsTable := ` createProjectsTable := `
@@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.repetition_date, t.repetition_date,
t.progression_base, t.progression_base,
t.wishlist_id, t.wishlist_id,
t.config_id,
COALESCE(( COALESCE((
SELECT COUNT(*) SELECT COUNT(*)
FROM tasks st FROM tasks st
@@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var repetitionDate sql.NullString var repetitionDate sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64
var projectNames pq.StringArray var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray var subtaskProjectNames pq.StringArray
@@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&repetitionDate, &repetitionDate,
&progressionBase, &progressionBase,
&wishlistID, &wishlistID,
&configID,
&task.SubtasksCount, &task.SubtasksCount,
&projectNames, &projectNames,
&subtaskProjectNames, &subtaskProjectNames,
@@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
wishlistIDInt := int(wishlistID.Int64) wishlistIDInt := int(wishlistID.Int64)
task.WishlistID = &wishlistIDInt task.WishlistID = &wishlistIDInt
} }
if configID.Valid {
configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt
}
// Объединяем проекты из основной задачи и подзадач // Объединяем проекты из основной задачи и подзадач
allProjects := make(map[string]bool) allProjects := make(map[string]bool)
@@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString var repetitionDate sql.NullString
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string var repetitionPeriodStr string
@@ -6887,11 +6902,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base, SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period, CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
COALESCE(repetition_date, '') as repetition_date, COALESCE(repetition_date, '') as repetition_date,
wishlist_id wishlist_id,
config_id
FROM tasks FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan( `, taskID, userID).Scan(
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID,
) )
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
@@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
wishlistIDInt := int(wishlistID.Int64) wishlistIDInt := int(wishlistID.Int64)
task.WishlistID = &wishlistIDInt task.WishlistID = &wishlistIDInt
} }
if configID.Valid {
configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt
}
// Получаем награды основной задачи // Получаем награды основной задачи
rewards := make([]Reward, 0) rewards := make([]Reward, 0)
@@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
Subtasks: subtasks, Subtasks: subtasks,
} }
// Если задача - тест (есть config_id), загружаем данные конфигурации
if configID.Valid {
var wordsCount int
var maxCards sql.NullInt64
err := a.DB.QueryRow(`
SELECT words_count, max_cards
FROM configs
WHERE id = $1
`, configID.Int64).Scan(&wordsCount, &maxCards)
if err == nil {
response.WordsCount = &wordsCount
if maxCards.Valid {
maxCardsInt := int(maxCards.Int64)
response.MaxCards = &maxCardsInt
}
// Загружаем связанные словари
dictRows, err := a.DB.Query(`
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $1
`, configID.Int64)
if err == nil {
defer dictRows.Close()
dictionaryIDs := make([]int, 0)
for dictRows.Next() {
var dictID int
if err := dictRows.Scan(&dictID); err == nil {
dictionaryIDs = append(dictionaryIDs, dictID)
}
}
if len(dictionaryIDs) > 0 {
response.DictionaryIDs = dictionaryIDs
}
}
} else {
log.Printf("Error loading config for task %d: %v", taskID, err)
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
@@ -7365,6 +7426,66 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Если это тест, создаем конфигурацию
if req.IsTest {
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
if req.WordsCount == nil || *req.WordsCount < 1 {
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
return
}
if len(req.DictionaryIDs) == 0 {
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
return
}
// Создаем конфигурацию теста
var configID int
if req.MaxCards != nil {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count, max_cards)
VALUES ($1, $2, $3)
RETURNING id
`, userID, *req.WordsCount, *req.MaxCards).Scan(&configID)
} else {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count)
VALUES ($1, $2)
RETURNING id
`, userID, *req.WordsCount).Scan(&configID)
}
if err != nil {
log.Printf("Error creating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
return
}
// Связываем конфигурацию со словарями
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id)
VALUES ($1, $2)
`, configID, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
// Обновляем задачу, привязывая config_id
_, err = tx.Exec(`
UPDATE tasks SET config_id = $1 WHERE id = $2
`, configID, taskID)
if err != nil {
log.Printf("Error linking config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Created test config %d for task %d", configID, taskID)
}
// Коммитим транзакцию // Коммитим транзакцию
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err) log.Printf("Error committing transaction: %v", err)
@@ -7771,6 +7892,116 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Получаем текущий config_id задачи
var currentConfigID sql.NullInt64
err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(&currentConfigID)
if err != nil {
log.Printf("Error getting current config_id: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError)
return
}
// Обработка конфигурации теста
if req.IsTest {
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
if req.WordsCount == nil || *req.WordsCount < 1 {
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
return
}
if len(req.DictionaryIDs) == 0 {
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
return
}
if currentConfigID.Valid {
// Обновляем существующую конфигурацию
if req.MaxCards != nil {
_, err = tx.Exec(`
UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3
`, *req.WordsCount, *req.MaxCards, currentConfigID.Int64)
} else {
_, err = tx.Exec(`
UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2
`, *req.WordsCount, currentConfigID.Int64)
}
if err != nil {
log.Printf("Error updating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError)
return
}
// Обновляем связи со словарями
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config dictionaries: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError)
return
}
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
`, currentConfigID.Int64, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
} else {
// Создаем новую конфигурацию для существующей задачи
var newConfigID int
if req.MaxCards != nil {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id
`, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID)
} else {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id
`, userID, *req.WordsCount).Scan(&newConfigID)
}
if err != nil {
log.Printf("Error creating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
return
}
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
`, newConfigID, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
_, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID)
if err != nil {
log.Printf("Error linking config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
return
}
}
} else if currentConfigID.Valid {
// Задача перестала быть тестом - удаляем конфигурацию
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config dictionaries: %v", err)
}
_, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config: %v", err)
}
_, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID)
if err != nil {
log.Printf("Error unlinking config from task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError)
return
}
}
// Коммитим транзакцию // Коммитим транзакцию
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err) log.Printf("Error committing transaction: %v", err)

View File

@@ -0,0 +1,49 @@
-- Migration: Refactor configs to link via tasks.config_id
-- This migration adds config_id to tasks table and migrates existing configs to tasks
-- After migration: configs only contain words_count, max_cards (name and try_message removed)
-- ============================================
-- Step 1: Add config_id to tasks
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_config_id ON tasks(config_id);
-- Unique index: only one task per config
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_config_id_unique
ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
-- ============================================
-- Step 2: Migrate existing configs to tasks
-- Create a task for each config that doesn't have one yet
-- ============================================
INSERT INTO tasks (user_id, name, reward_message, repetition_period, repetition_date, config_id)
SELECT
c.user_id,
c.name, -- Config name -> Task name
c.try_message, -- try_message -> reward_message
'0 day'::INTERVAL, -- repetition_period = 0 (infinite task)
'0 week', -- repetition_date = 0 (infinite task)
c.id -- Link to config
FROM configs c
WHERE c.name IS NOT NULL -- Only configs with names
AND NOT EXISTS (
SELECT 1 FROM tasks t WHERE t.config_id = c.id AND t.deleted = FALSE
);
-- ============================================
-- Step 3: Remove name and try_message from configs
-- These are now stored in the linked task
-- ============================================
ALTER TABLE configs DROP COLUMN IF EXISTS name;
ALTER TABLE configs DROP COLUMN IF EXISTS try_message;
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.10.8", "version": "3.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -4,8 +4,7 @@ import FullStatistics from './components/FullStatistics'
import ProjectPriorityManager from './components/ProjectPriorityManager' import ProjectPriorityManager from './components/ProjectPriorityManager'
import WordList from './components/WordList' import WordList from './components/WordList'
import AddWords from './components/AddWords' import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection' import DictionaryList from './components/DictionaryList'
import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords' import TestWords from './components/TestWords'
import Profile from './components/Profile' import Profile from './components/Profile'
import TaskList from './components/TaskList' import TaskList from './components/TaskList'
@@ -24,8 +23,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком) // Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile'] const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
function AppContent() { function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -51,8 +50,7 @@ function AppContent() {
full: false, full: false,
words: false, words: false,
'add-words': false, 'add-words': false,
'test-config': false, dictionaries: false,
'add-config': false,
test: false, test: false,
tasks: false, tasks: false,
'task-form': false, 'task-form': false,
@@ -71,8 +69,7 @@ function AppContent() {
full: false, full: false,
words: false, words: false,
'add-words': false, 'add-words': false,
'test-config': false, dictionaries: false,
'add-config': false,
test: false, test: false,
tasks: false, tasks: false,
'task-form': false, 'task-form': false,
@@ -113,7 +110,7 @@ function AppContent() {
// Состояние для кнопки Refresh (если она есть) // Состояние для кнопки Refresh (если она есть)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0) const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0) const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
@@ -128,7 +125,7 @@ function AppContent() {
// Проверяем URL только для глубоких табов // Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab') const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его // Если в URL есть глубокий таб, восстанавливаем его
@@ -381,8 +378,7 @@ function AppContent() {
full: false, full: false,
words: false, words: false,
'add-words': false, 'add-words': false,
'test-config': false, dictionaries: false,
'add-config': false,
test: false, test: false,
tasks: false, tasks: false,
'task-form': false, 'task-form': false,
@@ -452,17 +448,17 @@ function AppContent() {
// Возврат на таб - фоновая загрузка // Возврат на таб - фоновая загрузка
setPrioritiesRefreshTrigger(prev => prev + 1) setPrioritiesRefreshTrigger(prev => prev + 1)
} }
} else if (tab === 'test-config') { } else if (tab === 'dictionaries') {
const isInitialized = tabsInitializedRef.current['test-config'] const isInitialized = tabsInitializedRef.current['dictionaries']
if (!isInitialized) { if (!isInitialized) {
// Первая загрузка таба // Первая загрузка таба
setTestConfigRefreshTrigger(prev => prev + 1) setDictionariesRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current['test-config'] = true tabsInitializedRef.current['dictionaries'] = true
setTabsInitialized(prev => ({ ...prev, 'test-config': true })) setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
} else if (isBackground) { } else if (isBackground) {
// Возврат на таб - фоновая загрузка // Возврат на таб - фоновая загрузка
setTestConfigRefreshTrigger(prev => prev + 1) setDictionariesRefreshTrigger(prev => prev + 1)
} }
} else if (tab === 'tasks') { } else if (tab === 'tasks') {
const hasCache = cacheRef.current.tasks !== null const hasCache = cacheRef.current.tasks !== null
@@ -502,7 +498,7 @@ function AppContent() {
// Обработчик кнопки "назад" в браузере (только для глубоких табов) // Обработчик кнопки "назад" в браузере (только для глубоких табов)
useEffect(() => { useEffect(() => {
const handlePopState = (event) => { const handlePopState = (event) => {
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
// Проверяем state текущей записи истории (куда мы вернулись) // Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) { if (event.state && event.state.tab) {
@@ -617,15 +613,7 @@ function AppContent() {
const isCurrentTabMain = mainTabs.includes(activeTab) const isCurrentTabMain = mainTabs.includes(activeTab)
const isNewTabMain = mainTabs.includes(tab) const isNewTabMain = mainTabs.includes(tab)
// Сбрасываем tabParams при переходе с add-config на другой таб {
if (activeTab === 'add-config' && tab !== 'add-config') {
setTabParams({})
if (isNewTabMain) {
clearUrl()
} else if (isNewTabDeep) {
updateUrl(tab, {}, activeTab)
}
} else {
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания) // task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
@@ -723,7 +711,7 @@ function AppContent() {
}, [activeTab]) }, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'dictionaries'
// Определяем отступы для контейнера // Определяем отступы для контейнера
const getContainerPadding = () => { const getContainerPadding = () => {
@@ -818,21 +806,11 @@ function AppContent() {
</div> </div>
)} )}
{loadedTabs['test-config'] && ( {loadedTabs.dictionaries && (
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}> <div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
<TestConfigSelection <DictionaryList
onNavigate={handleNavigate} onNavigate={handleNavigate}
refreshTrigger={testConfigRefreshTrigger} refreshTrigger={dictionariesRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-config'] && (
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
<AddConfig
key={tabParams.config?.id || 'new'}
onNavigate={handleNavigate}
editingConfig={tabParams.config}
/> />
</div> </div>
)} )}
@@ -844,6 +822,7 @@ function AppContent() {
wordCount={tabParams.wordCount} wordCount={tabParams.wordCount}
configId={tabParams.configId} configId={tabParams.configId}
maxCards={tabParams.maxCards} maxCards={tabParams.maxCards}
taskId={tabParams.taskId}
/> />
</div> </div>
)} )}
@@ -948,27 +927,6 @@ function AppContent() {
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div> <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)} )}
</button> </button>
<button
onClick={() => handleTabChange('test-config')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'test-config' || activeTab === 'test'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Тест"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path d="M8 7h6"></path>
<path d="M8 11h4"></path>
</svg>
</span>
{(activeTab === 'test-config' || activeTab === 'test') && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button <button
onClick={() => handleTabChange('tasks')} onClick={() => handleTabChange('tasks')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${ className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${

View File

@@ -1,222 +0,0 @@
.add-config {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.add-config {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-config h2 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
font-size: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
font-family: inherit;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #3498db;
}
.submit-button {
background-color: #3498db;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
.submit-button:hover:not(:disabled) {
background-color: #2980b9;
}
.submit-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
font-weight: 500;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.stepper-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stepper-button {
background-color: #3498db;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stepper-button:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-1px);
}
.stepper-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
opacity: 0.6;
}
.stepper-input {
flex: 1;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
text-align: center;
transition: border-color 0.2s;
font-family: inherit;
}
.stepper-input:focus {
outline: none;
border-color: #3498db;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.dictionaries-hint {
font-size: 0.875rem;
color: #7f8c8d;
margin-bottom: 0.75rem;
font-style: italic;
}
.dictionaries-checkbox-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
border: 2px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.dictionary-checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.dictionary-checkbox-label:hover {
background-color: #e8f4f8;
}
.dictionary-checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
margin: 0;
margin-right: 0.75rem;
padding: 0;
cursor: pointer;
accent-color: #3498db;
flex-shrink: 0;
align-self: center;
vertical-align: middle;
}
.dictionary-checkbox-label span {
color: #2c3e50;
font-size: 0.95rem;
line-height: 18px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -1,346 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddConfig.css'
const API_URL = '/api'
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [tryMessage, setTryMessage] = useState('')
const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [dictionaries, setDictionaries] = useState([])
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
// Load dictionaries
useEffect(() => {
const loadDictionaries = async () => {
setLoadingDictionaries(true)
try {
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const data = await response.json()
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
} catch (err) {
console.error('Failed to load dictionaries:', err)
} finally {
setLoadingDictionaries(false)
}
}
loadDictionaries()
}, [])
// Load selected dictionaries when editing
useEffect(() => {
const loadSelectedDictionaries = async () => {
if (initialEditingConfig?.id) {
try {
const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
if (response.ok) {
const data = await response.json()
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
}
} catch (err) {
console.error('Failed to load selected dictionaries:', err)
}
} else {
setSelectedDictionaryIds([])
}
}
loadSelectedDictionaries()
}, [initialEditingConfig])
useEffect(() => {
if (initialEditingConfig) {
setName(initialEditingConfig.name)
setTryMessage(initialEditingConfig.try_message)
setWordsCount(String(initialEditingConfig.words_count))
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
} else {
// Сбрасываем состояние при открытии в режиме добавления
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setSelectedDictionaryIds([])
}
}, [initialEditingConfig])
// Сбрасываем состояние при размонтировании компонента
useEffect(() => {
return () => {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setLoading(false)
}
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setMessage('')
setLoading(true)
if (!name.trim()) {
setMessage('Имя обязательно для заполнения.')
setLoading(false)
return
}
try {
const url = initialEditingConfig
? `${API_URL}/configs/${initialEditingConfig.id}`
: `${API_URL}/configs`
const method = initialEditingConfig ? 'PUT' : 'POST'
const response = await authFetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.trim(),
try_message: tryMessage.trim() || '',
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
throw new Error(errorMessage)
}
if (!initialEditingConfig) {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
}
// Navigate back immediately
onNavigate?.('test-config')
} catch (error) {
setMessage(`Ошибка: ${error.message}`)
} finally {
setLoading(false)
}
}
const getNumericValue = () => {
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
}
const getMaxCardsNumericValue = () => {
return maxCards === '' ? 0 : parseInt(maxCards) || 0
}
const handleDecrease = () => {
const numValue = getNumericValue()
if (numValue > 0) {
setWordsCount(String(numValue - 1))
}
}
const handleIncrease = () => {
const numValue = getNumericValue()
setWordsCount(String(numValue + 1))
}
const handleMaxCardsDecrease = () => {
const numValue = getMaxCardsNumericValue()
if (numValue > 0) {
setMaxCards(String(numValue - 1))
} else {
setMaxCards('')
}
}
const handleMaxCardsIncrease = () => {
const numValue = getMaxCardsNumericValue()
const newValue = numValue + 1
setMaxCards(String(newValue))
}
const handleClose = () => {
// Сбрасываем состояние при закрытии
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
onNavigate?.('test-config')
}
return (
<div className="add-config">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Конфигурация теста</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Имя</label>
<input
id="name"
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Название конфига"
required
/>
</div>
<div className="form-group">
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
<textarea
id="tryMessage"
className="form-textarea"
value={tryMessage}
onChange={(e) => setTryMessage(e.target.value)}
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
rows={4}
/>
</div>
<div className="form-group">
<label htmlFor="wordsCount">Кол-во слов</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleDecrease}
disabled={getNumericValue() <= 0}
>
</button>
<input
id="wordsCount"
type="number"
className="stepper-input"
value={wordsCount}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setWordsCount('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setWordsCount(inputValue)
}
}
}}
min="0"
required
/>
<button
type="button"
className="stepper-button"
onClick={handleIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsDecrease}
disabled={getMaxCardsNumericValue() <= 0}
>
</button>
<input
id="maxCards"
type="number"
className="stepper-input"
value={maxCards}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setMaxCards('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setMaxCards(inputValue)
}
}
}}
min="0"
/>
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="dictionaries">Словари (необязательно)</label>
<div className="dictionaries-hint">
Если не выбрано ни одного словаря, будут использоваться все словари
</div>
{loadingDictionaries ? (
<div>Загрузка словарей...</div>
) : (
<div className="dictionaries-checkbox-list">
{dictionaries.map((dict) => (
<label key={dict.id} className="dictionary-checkbox-label">
<input
type="checkbox"
checked={selectedDictionaryIds.includes(dict.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
} else {
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
}
}}
/>
<span>{dict.name} ({dict.wordsCount})</span>
</label>
))}
</div>
)}
</div>
<button
type="submit"
className="submit-button"
disabled={loading || !name.trim() || getNumericValue() === 0}
>
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
</button>
</form>
{message && (
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
{message}
</div>
)}
</div>
)
}
export default AddConfig

View File

@@ -1,261 +1,31 @@
.config-selection { .dictionary-list {
padding-top: 0; padding-top: 0;
}
.add-config-button {
background: transparent;
border: 2px dashed #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative; position: relative;
} }
.add-config-button:hover { .dictionary-back-button {
transform: translateY(-2px); position: absolute;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); top: 0;
background-color: rgba(52, 152, 219, 0.05); left: 0;
border-color: #2980b9; background: transparent;
border: none;
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
color: #2c3e50;
transition: all 0.2s;
z-index: 10;
} }
.add-config-icon { .dictionary-back-button:hover {
font-size: 3rem; background: rgba(0, 0, 0, 0.05);
font-weight: bold;
color: #3498db;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
} }
.add-config-text { .dictionaries-grid {
font-size: 1rem;
font-weight: 500;
color: #3498db;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.loading, .error-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.configs-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem; gap: 1rem;
} padding-top: 2.5rem;
.config-card {
background: #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.config-selection .config-card .card-menu-button {
position: absolute;
top: 0.5rem;
right: 0;
background: transparent !important;
border: none !important;
border-radius: 6px !important;
width: 40px !important;
height: 40px !important;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.5rem !important;
color: white !important;
font-weight: bold;
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
}
.config-selection .config-card .card-menu-button:hover {
background: transparent !important;
opacity: 0.7;
transform: scale(1.1);
}
.config-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.config-words-count {
font-size: 2.5rem;
font-weight: bold;
color: white;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.config-max-cards {
font-size: 1.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
margin-top: -1rem;
margin-bottom: auto;
}
.config-name {
font-size: 1rem;
font-weight: 500;
color: white;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.config-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.config-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.config-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.config-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.config-modal-close:hover {
background-color: #f0f0f0;
}
.config-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.config-modal-edit,
.config-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.config-modal-edit {
background-color: #3498db;
color: white;
}
.config-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.config-modal-delete {
background-color: #e74c3c;
color: white;
}
.config-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}
.section-divider {
margin: 0.5rem 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 2px solid #e0e0e0;
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
}
.dictionaries-section {
margin-top: 2rem;
} }
.dictionary-card { .dictionary-card {
@@ -273,7 +43,7 @@
position: relative; position: relative;
} }
.config-selection .dictionary-card .card-menu-button { .dictionary-list .dictionary-card .dictionary-menu-button {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0; right: 0;
@@ -295,7 +65,7 @@
line-height: 1; line-height: 1;
} }
.config-selection .dictionary-card .card-menu-button:hover { .dictionary-list .dictionary-card .dictionary-menu-button:hover {
opacity: 0.7; opacity: 0.7;
transform: scale(1.1); transform: scale(1.1);
} }
@@ -347,11 +117,99 @@
border-color: #1a252f; border-color: #1a252f;
} }
.add-dictionary-button .add-config-icon { .add-dictionary-icon {
font-size: 3rem;
font-weight: bold;
color: #000000; color: #000000;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
} }
.add-dictionary-button .add-config-text { .add-dictionary-text {
font-size: 1rem;
font-weight: 500;
color: #000000; color: #000000;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
/* Modal styles */
.dictionary-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dictionary-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: dictionaryModalSlideIn 0.2s ease-out;
}
@keyframes dictionaryModalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.dictionary-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.dictionary-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.dictionary-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.dictionary-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background-color: #e74c3c;
color: white;
}
.dictionary-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
} }

View File

@@ -0,0 +1,176 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './DictionaryList.css'
const API_URL = '/api'
function DictionaryList({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [dictionaries, setDictionaries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedDictionary, setSelectedDictionary] = useState(null)
const isInitializedRef = useRef(false)
const dictionariesRef = useRef([])
// Обновляем ref при изменении состояния
useEffect(() => {
dictionariesRef.current = dictionaries
}, [dictionaries])
useEffect(() => {
fetchDictionaries()
}, [refreshTrigger])
const fetchDictionaries = async () => {
try {
// Показываем загрузку только при первой инициализации или если нет данных для отображения
const isFirstLoad = !isInitializedRef.current
const hasData = !isFirstLoad && dictionariesRef.current.length > 0
if (!hasData) {
setLoading(true)
}
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const data = await response.json()
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
setError('')
isInitializedRef.current = true
} catch (err) {
setError(err.message)
setDictionaries([])
isInitializedRef.current = true
} finally {
setLoading(false)
}
}
const handleDictionarySelect = (dict) => {
onNavigate?.('words', { dictionaryId: dict.id })
}
const handleDictionaryMenuClick = (dict, e) => {
e.stopPropagation()
setSelectedDictionary(dict)
}
const handleDictionaryDelete = async () => {
if (!selectedDictionary) return
try {
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
}
setSelectedDictionary(null)
// Refresh dictionaries list
await fetchDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedDictionary(null)
}
}
const closeDictionaryModal = () => {
setSelectedDictionary(null)
}
// Показываем загрузку только при первой инициализации и если нет данных для отображения
const shouldShowLoading = loading && !isInitializedRef.current && dictionaries.length === 0
if (shouldShowLoading) {
return (
<div className="dictionary-list">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="dictionary-list">
<LoadingError onRetry={fetchDictionaries} />
</div>
)
}
return (
<div className="dictionary-list">
{/* Кнопка назад */}
<button
className="dictionary-back-button"
onClick={() => onNavigate?.('profile')}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>
<div className="dictionaries-grid">
{dictionaries.map((dict) => (
<div
key={dict.id}
className="dictionary-card"
onClick={() => handleDictionarySelect(dict)}
>
<button
onClick={(e) => handleDictionaryMenuClick(dict, e)}
className="dictionary-menu-button"
title="Меню"
>
</button>
<div className="dictionary-words-count">
{dict.wordsCount}
</div>
<div className="dictionary-name">{dict.name}</div>
</div>
))}
<button
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
className="add-dictionary-button"
>
<div className="add-dictionary-icon">+</div>
<div className="add-dictionary-text">Добавить</div>
</button>
</div>
{selectedDictionary && (
<div className="dictionary-modal-overlay" onClick={closeDictionaryModal}>
<div className="dictionary-modal" onClick={(e) => e.stopPropagation()}>
<div className="dictionary-modal-header">
<h3>{selectedDictionary.name}</h3>
</div>
<div className="dictionary-modal-actions">
<button className="dictionary-modal-delete" onClick={handleDictionaryDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default DictionaryList

View File

@@ -35,6 +35,36 @@ function Profile({ onNavigate }) {
</div> </div>
</div> </div>
{/* Features Section */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Функционал</h2>
<div className="space-y-3">
<button
onClick={() => onNavigate?.('dictionaries')}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
Словари
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
</button>
</div>
</div>
{/* Integrations Section */} {/* Integrations Section */}
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2> <h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>

View File

@@ -413,3 +413,99 @@
color: #ef4444; color: #ef4444;
} }
/* Test configuration styles */
.test-config-section {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.5rem;
padding: 1rem;
}
.test-config-section > label {
font-size: 1rem;
font-weight: 600;
color: #3498db;
margin-bottom: 1rem !important;
}
.test-config-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.test-field-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.test-field-group label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.test-dictionaries-section {
margin-top: 1rem;
}
.test-dictionaries-section > label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
display: block;
}
.test-dictionaries-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.test-dictionary-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s;
}
.test-dictionary-item:hover {
background-color: #f3f4f6;
}
.test-dictionary-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #3498db;
}
.test-dictionary-name {
flex: 1;
font-weight: 500;
color: #374151;
}
.test-dictionary-count {
font-size: 0.875rem;
color: #9ca3af;
}
.test-no-dictionaries {
padding: 1rem;
text-align: center;
color: #6b7280;
font-style: italic;
}

View File

@@ -6,7 +6,7 @@ import './TaskForm.css'
const API_URL = '/api/tasks' const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects' const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId, wishlistId }) { function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('') const [progressionBase, setProgressionBase] = useState('')
@@ -24,6 +24,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
// Test-specific state
const [isTest, setIsTest] = useState(isTestFromProps)
const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('')
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
const [availableDictionaries, setAvailableDictionaries] = useState([])
const debounceTimer = useRef(null) const debounceTimer = useRef(null)
// Загрузка проектов для автокомплита // Загрузка проектов для автокомплита
@@ -42,6 +48,22 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
loadProjects() loadProjects()
}, []) }, [])
// Загрузка словарей для тестов
useEffect(() => {
const loadDictionaries = async () => {
try {
const response = await authFetch('/api/test-configs-and-dictionaries')
if (response.ok) {
const data = await response.json()
setAvailableDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
}
} catch (err) {
console.error('Error loading dictionaries:', err)
}
}
loadDictionaries()
}, [])
// Функция сброса формы // Функция сброса формы
const resetForm = () => { const resetForm = () => {
setName('') setName('')
@@ -54,6 +76,11 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
setSubtasks([]) setSubtasks([])
setError('') setError('')
setLoadingTask(false) setLoadingTask(false)
// Reset test-specific fields
setIsTest(isTestFromProps)
setWordsCount('10')
setMaxCards('')
setSelectedDictionaryIDs([])
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = null debounceTimer.current = null
@@ -316,6 +343,28 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
setCurrentWishlistId(null) setCurrentWishlistId(null)
setWishlistInfo(null) setWishlistInfo(null)
} }
// Загружаем информацию о тесте, если есть config_id
if (data.task.config_id) {
setIsTest(true)
// Данные теста приходят прямо в ответе getTaskDetail
if (data.words_count) {
setWordsCount(String(data.words_count))
}
if (data.max_cards) {
setMaxCards(String(data.max_cards))
}
if (data.dictionary_ids && Array.isArray(data.dictionary_ids)) {
setSelectedDictionaryIDs(data.dictionary_ids)
}
// Тесты не могут иметь прогрессию
setProgressionBase('')
} else {
setIsTest(false)
setWordsCount('10')
setMaxCards('')
setSelectedDictionaryIDs([])
}
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -551,11 +600,26 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
} }
} }
// Валидация для тестов
if (isTest) {
const wordsCountNum = parseInt(wordsCount, 10)
if (isNaN(wordsCountNum) || wordsCountNum < 1) {
setError('Количество слов должно быть минимум 1')
setLoading(false)
return
}
if (selectedDictionaryIDs.length === 0) {
setError('Выберите хотя бы один словарь')
setLoading(false)
return
}
}
const payload = { const payload = {
name: name.trim(), name: name.trim(),
reward_message: rewardMessage.trim() || null, reward_message: rewardMessage.trim() || null,
// Если задача привязана к желанию, не отправляем progression_base // Тесты и задачи с желанием не могут иметь прогрессию
progression_base: isLinkedToWishlist ? null : (progressionBase ? parseFloat(progressionBase) : null), progression_base: (isLinkedToWishlist || isTest) ? null : (progressionBase ? parseFloat(progressionBase) : null),
repetition_period: repetitionPeriod, repetition_period: repetitionPeriod,
repetition_date: repetitionDate, repetition_date: repetitionDate,
// При создании: отправляем currentWishlistId если указан (уже число) // При создании: отправляем currentWishlistId если указан (уже число)
@@ -580,7 +644,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
value: parseFloat(r.value) || 0, value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression) use_progression: !!(progressionBase && r.use_progression)
})) }))
})) })),
// Test-specific fields
is_test: isTest,
words_count: isTest ? parseInt(wordsCount, 10) : undefined,
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
dictionary_ids: isTest ? selectedDictionaryIDs : undefined
} }
const url = taskId ? `${API_URL}/${taskId}` : API_URL const url = taskId ? `${API_URL}/${taskId}` : API_URL
@@ -715,26 +784,88 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
</div> </div>
)} )}
<div className="form-group"> {!isTest && (
<label htmlFor="progression_base">Прогрессия</label> <div className="form-group">
<input <label htmlFor="progression_base">Прогрессия</label>
id="progression_base" <input
type="number" id="progression_base"
step="any" type="number"
value={progressionBase} step="any"
onChange={(e) => { value={progressionBase}
if (!wishlistInfo) { onChange={(e) => {
setProgressionBase(e.target.value) if (!wishlistInfo) {
} setProgressionBase(e.target.value)
}} }
placeholder="Базовое значение" }}
className="form-input" placeholder="Базовое значение"
disabled={wishlistInfo !== null} className="form-input"
/> disabled={wishlistInfo !== null}
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}> />
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'} <small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}>
</small> {wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
</div> </small>
</div>
)}
{/* Test-specific fields */}
{isTest && (
<div className="form-group test-config-section">
<label>Настройки теста</label>
<div className="test-config-fields">
<div className="test-field-group">
<label htmlFor="words_count">Количество слов *</label>
<input
id="words_count"
type="number"
min="1"
value={wordsCount}
onChange={(e) => setWordsCount(e.target.value)}
className="form-input"
required
/>
</div>
<div className="test-field-group">
<label htmlFor="max_cards">Макс. карточек</label>
<input
id="max_cards"
type="number"
min="1"
value={maxCards}
onChange={(e) => setMaxCards(e.target.value)}
placeholder="Без ограничения"
className="form-input"
/>
</div>
</div>
<div className="test-dictionaries-section">
<label>Словари *</label>
<div className="test-dictionaries-list">
{availableDictionaries.map(dict => (
<label key={dict.id} className="test-dictionary-item">
<input
type="checkbox"
checked={selectedDictionaryIDs.includes(dict.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDictionaryIDs([...selectedDictionaryIDs, dict.id])
} else {
setSelectedDictionaryIDs(selectedDictionaryIDs.filter(id => id !== dict.id))
}
}}
/>
<span className="test-dictionary-name">{dict.name}</span>
<span className="test-dictionary-count">({dict.wordsCount} слов)</span>
</label>
))}
{availableDictionaries.length === 0 && (
<div className="test-no-dictionaries">
Нет доступных словарей. Создайте словарь в разделе "Словари".
</div>
)}
</div>
</div>
</div>
)}
<div className="form-group"> <div className="form-group">
<label htmlFor="repetition_period">Повторения</label> <label htmlFor="repetition_period">Повторения</label>

View File

@@ -512,3 +512,101 @@
font-style: italic; font-style: italic;
} }
/* Badge icons for test and wishlist tasks */
.task-test-icon {
color: #3498db;
flex-shrink: 0;
}
.task-wishlist-icon {
color: #e74c3c;
flex-shrink: 0;
}
/* Add task/test modal */
.task-add-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.task-add-modal {
background: white;
border-radius: 0.75rem;
max-width: 320px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.task-add-modal-header {
padding: 1.25rem 1.5rem 0.75rem;
text-align: center;
}
.task-add-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.task-add-modal-buttons {
padding: 0 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-add-modal-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.task-add-modal-button-task {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
}
.task-add-modal-button-task:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.task-add-modal-button-test {
background: linear-gradient(to right, #3498db, #2980b9);
color: white;
}
.task-add-modal-button-test:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}

View File

@@ -18,6 +18,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const [postponeDate, setPostponeDate] = useState('') const [postponeDate, setPostponeDate] = useState('')
const [isPostponing, setIsPostponing] = useState(false) const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const dateInputRef = useRef(null) const dateInputRef = useRef(null)
useEffect(() => { useEffect(() => {
@@ -36,7 +37,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const handleCheckmarkClick = async (task, e) => { const handleCheckmarkClick = async (task, e) => {
e.stopPropagation() e.stopPropagation()
// Всегда открываем диалог подтверждения // Для задач-тестов запускаем тест вместо открытия модального окна
const isTest = task.config_id != null
if (isTest) {
if (task.config_id) {
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
}
return
}
// Для обычных задач открываем диалог подтверждения
setSelectedTaskForDetail(task.id) setSelectedTaskForDetail(task.id)
} }
@@ -45,9 +55,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
} }
const handleAddClick = () => { const handleAddClick = () => {
onNavigate?.('task-form', { taskId: undefined }) setShowAddModal(true)
} }
const handleAddTask = () => {
setShowAddModal(false)
onNavigate?.('task-form', { taskId: undefined, isTest: false })
}
const handleAddTest = () => {
setShowAddModal(false)
onNavigate?.('task-form', { taskId: undefined, isTest: true })
}
// Функция для вычисления следующей даты по repetition_date // Функция для вычисления следующей даты по repetition_date
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => { const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
if (!repetitionDateStr) return null if (!repetitionDateStr) return null
@@ -490,6 +511,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const hasProgression = task.has_progression || task.progression_base != null const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0 const hasSubtasks = task.subtasks_count > 0
const showDetailOnCheckmark = hasProgression || hasSubtasks const showDetailOnCheckmark = hasProgression || hasSubtasks
const isTest = task.config_id != null
const isWishlist = task.wishlist_id != null
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует) // Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной // Для обратной совместимости: если repetition_period = 0, считаем бесконечной
@@ -513,7 +536,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<div <div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`} className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)} onClick={(e) => handleCheckmarkClick(task, e)}
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'} title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" /> <circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
@@ -528,6 +551,43 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<span className="task-subtasks-count">(+{task.subtasks_count})</span> <span className="task-subtasks-count">(+{task.subtasks_count})</span>
)} )}
<span className="task-badge-bar"> <span className="task-badge-bar">
{isWishlist && (
<svg
className="task-wishlist-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Связано с желанием"
>
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
)}
{isTest && (
<svg
className="task-test-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Тест"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
)}
{hasProgression && ( {hasProgression && (
<svg <svg
className="task-progression-icon" className="task-progression-icon"
@@ -741,6 +801,41 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
/> />
)} )}
{/* Модальное окно выбора типа задачи */}
{showAddModal && (
<div className="task-add-modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="task-add-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-add-modal-header">
<h3>Что добавить?</h3>
</div>
<div className="task-add-modal-buttons">
<button
className="task-add-modal-button task-add-modal-button-task"
onClick={handleAddTask}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
Задача
</button>
<button
className="task-add-modal-button task-add-modal-button-test"
onClick={handleAddTest}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path d="M8 7h6"></path>
<path d="M8 11h4"></path>
</svg>
Тест
</button>
</div>
</div>
</div>
)}
{/* Модальное окно для переноса задачи */} {/* Модальное окно для переноса задачи */}
{selectedTaskForPostpone && (() => { {selectedTaskForPostpone && (() => {
const todayStr = formatDateToLocal(new Date()) const todayStr = formatDateToLocal(new Date())

View File

@@ -1,286 +0,0 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './TestConfigSelection.css'
const API_URL = '/api'
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [configs, setConfigs] = useState([])
const [dictionaries, setDictionaries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedConfig, setSelectedConfig] = useState(null)
const [selectedDictionary, setSelectedDictionary] = useState(null)
const [longPressTimer, setLongPressTimer] = useState(null)
const isInitializedRef = useRef(false)
const configsRef = useRef([])
const dictionariesRef = useRef([])
// Обновляем ref при изменении состояния
useEffect(() => {
configsRef.current = configs
}, [configs])
useEffect(() => {
dictionariesRef.current = dictionaries
}, [dictionaries])
useEffect(() => {
fetchTestConfigsAndDictionaries()
}, [refreshTrigger])
const fetchTestConfigsAndDictionaries = async () => {
try {
// Показываем загрузку только при первой инициализации или если нет данных для отображения
const isFirstLoad = !isInitializedRef.current
const hasData = !isFirstLoad && (configsRef.current.length > 0 || dictionariesRef.current.length > 0)
if (!hasData) {
setLoading(true)
}
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке конфигураций и словарей')
}
const data = await response.json()
setConfigs(Array.isArray(data.configs) ? data.configs : [])
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
setError('')
isInitializedRef.current = true
} catch (err) {
setError(err.message)
setConfigs([])
setDictionaries([])
isInitializedRef.current = true
} finally {
setLoading(false)
}
}
const handleConfigSelect = (config) => {
onNavigate?.('test', {
wordCount: config.words_count,
configId: config.id,
maxCards: config.max_cards || null
})
}
const handleDictionarySelect = (dict) => {
// For now, navigate to words list
// In the future, we might want to filter by dictionary_id
onNavigate?.('words', { dictionaryId: dict.id })
}
const handleConfigMenuClick = (config, e) => {
e.stopPropagation()
setSelectedConfig(config)
}
const handleDictionaryMenuClick = (dict, e) => {
e.stopPropagation()
setSelectedDictionary(dict)
}
const handleEdit = () => {
if (selectedConfig) {
onNavigate?.('add-config', { config: selectedConfig })
setSelectedConfig(null)
}
}
const handleDictionaryDelete = async () => {
if (!selectedDictionary) return
try {
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
}
setSelectedDictionary(null)
// Refresh dictionaries list
await fetchTestConfigsAndDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedDictionary(null)
}
}
const handleDelete = async () => {
if (!selectedConfig) return
try {
const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении конфигурации: ${response.status}`)
}
setSelectedConfig(null)
// Refresh configs and dictionaries list
await fetchTestConfigsAndDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedConfig(null)
}
}
const closeModal = () => {
setSelectedConfig(null)
}
// Показываем загрузку только при первой инициализации и если нет данных для отображения
const shouldShowLoading = loading && !isInitializedRef.current && configs.length === 0 && dictionaries.length === 0
if (shouldShowLoading) {
return (
<div className="config-selection">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="config-selection">
<LoadingError onRetry={fetchTestConfigsAndDictionaries} />
</div>
)
}
return (
<div className="config-selection">
{/* Секция тестов */}
<div className="section-divider">
<h2 className="section-title">Тесты</h2>
</div>
<div className="configs-grid">
{configs.map((config) => (
<div
key={config.id}
className="config-card"
onClick={() => handleConfigSelect(config)}
>
<button
onClick={(e) => handleConfigMenuClick(config, e)}
className="card-menu-button"
title="Меню"
>
</button>
<div className="config-words-count">
{config.words_count}
</div>
{config.max_cards && (
<div className="config-max-cards">
{config.max_cards}
</div>
)}
<div className="config-name">{config.name}</div>
</div>
))}
<button onClick={() => onNavigate?.('add-config')} className="add-config-button">
<div className="add-config-icon">+</div>
<div className="add-config-text">Добавить</div>
</button>
</div>
{/* Секция словарей */}
<div className="dictionaries-section">
<div className="section-divider">
<h2 className="section-title">Словари</h2>
</div>
<div className="configs-grid">
{dictionaries.map((dict) => (
<div
key={dict.id}
className="dictionary-card"
onClick={() => handleDictionarySelect(dict)}
>
<button
onClick={(e) => handleDictionaryMenuClick(dict, e)}
className="card-menu-button"
title="Меню"
>
</button>
<div className="dictionary-words-count">
{dict.wordsCount}
</div>
<div className="dictionary-name">{dict.name}</div>
</div>
))}
<button
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
className="add-dictionary-button"
>
<div className="add-config-icon">+</div>
<div className="add-config-text">Добавить</div>
</button>
</div>
</div>
{selectedConfig && (
<div className="config-modal-overlay" onClick={closeModal}>
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
<div className="config-modal-header">
<h3>{selectedConfig.name}</h3>
</div>
<div className="config-modal-actions">
<button className="config-modal-edit" onClick={handleEdit}>
Редактировать
</button>
<button className="config-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
{selectedDictionary && (
<div className="config-modal-overlay" onClick={() => setSelectedDictionary(null)}>
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
<div className="config-modal-header">
<h3>{selectedDictionary.name}</h3>
</div>
<div className="config-modal-actions">
<button className="config-modal-delete" onClick={handleDictionaryDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default TestConfigSelection

View File

@@ -8,11 +8,12 @@ const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10 const DEFAULT_TEST_WORD_COUNT = 10
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) { function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
const configId = initialConfigId || null const configId = initialConfigId || null
const maxCards = initialMaxCards || null const maxCards = initialMaxCards || null
const taskId = initialTaskId || null
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики) const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
const [testWords, setTestWords] = useState([]) // Пул слов для показа const [testWords, setTestWords] = useState([]) // Пул слов для показа
@@ -366,6 +367,25 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const responseData = await response.json().catch(() => ({})) const responseData = await response.json().catch(() => ({}))
console.log('Test progress saved successfully:', responseData) console.log('Test progress saved successfully:', responseData)
// Если есть taskId, выполняем задачу
if (taskId) {
try {
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (completeResponse.ok) {
console.log('Task completed successfully')
} else {
console.error('Failed to complete task:', await completeResponse.text())
}
} catch (taskErr) {
console.error('Failed to complete task:', taskErr)
}
}
} catch (err) { } catch (err) {
console.error('Failed to save progress:', err) console.error('Failed to save progress:', err)
// Можно показать уведомление пользователю, но не блокируем показ результатов // Можно показать уведомление пользователю, но не блокируем показ результатов
@@ -537,7 +557,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
} }
const handleClose = () => { const handleClose = () => {
onNavigate?.('test-config') onNavigate?.('tasks')
} }
const handleStartTest = () => { const handleStartTest = () => {
@@ -547,7 +567,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
} }
const handleFinish = () => { const handleFinish = () => {
onNavigate?.('test-config') onNavigate?.('tasks')
} }
const getRandomSide = (word) => { const getRandomSide = (word) => {

View File

@@ -189,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
return ( return (
<div className="word-list"> <div className="word-list">
<button <button
onClick={() => onNavigate?.('test-config')} onClick={() => onNavigate?.('dictionaries')}
className="close-x-button" className="close-x-button"
title="Закрыть" title="Закрыть"
> >