Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
This commit is contained in:
@@ -73,17 +73,13 @@ type TestProgressRequest struct {
|
||||
|
||||
type Config struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
TryMessage string `json:"try_message"`
|
||||
}
|
||||
|
||||
type ConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
TryMessage string `json:"try_message"`
|
||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||
}
|
||||
|
||||
@@ -214,6 +210,7 @@ type Task struct {
|
||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||
ConfigID *int `json:"config_id,omitempty"`
|
||||
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
||||
ProjectNames []string `json:"project_names"`
|
||||
SubtasksCount int `json:"subtasks_count"`
|
||||
@@ -237,6 +234,10 @@ type TaskDetail struct {
|
||||
Task Task `json:"task"`
|
||||
Rewards []Reward `json:"rewards"`
|
||||
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 {
|
||||
@@ -262,6 +263,11 @@ type TaskRequest struct {
|
||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||
Rewards []RewardRequest `json:"rewards,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 {
|
||||
@@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// If config_id is provided, send webhook with try_message
|
||||
if req.ConfigID != nil {
|
||||
configID := *req.ConfigID
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
|
||||
// The config_id is kept in the request for potential future use, but we no longer send messages here
|
||||
// to avoid duplicate messages (one from test completion, one from task completion).
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, name, words_count, max_cards, try_message
|
||||
SELECT id, words_count, max_cards
|
||||
FROM configs
|
||||
WHERE user_id = $1
|
||||
ORDER BY id
|
||||
@@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var maxCards sql.NullInt64
|
||||
err := rows.Scan(
|
||||
&config.ID,
|
||||
&config.Name,
|
||||
&config.WordsCount,
|
||||
&maxCards,
|
||||
&config.TryMessage,
|
||||
)
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
// Get configs
|
||||
configsQuery := `
|
||||
SELECT id, name, words_count, max_cards, try_message
|
||||
SELECT id, words_count, max_cards
|
||||
FROM configs
|
||||
WHERE user_id = $1
|
||||
ORDER BY id
|
||||
@@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
||||
var maxCards sql.NullInt64
|
||||
err := configsRows.Scan(
|
||||
&config.ID,
|
||||
&config.Name,
|
||||
&config.WordsCount,
|
||||
&maxCards,
|
||||
&config.TryMessage,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WordsCount <= 0 {
|
||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||
return
|
||||
@@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var id int
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (name, words_count, max_cards, try_message, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO configs (words_count, max_cards, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id)
|
||||
`, req.WordsCount, req.MaxCards, userID).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WordsCount <= 0 {
|
||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||
return
|
||||
@@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := tx.Exec(`
|
||||
UPDATE configs
|
||||
SET name = $1, words_count = $2, max_cards = $3, try_message = $4
|
||||
WHERE id = $5
|
||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID)
|
||||
SET words_count = $1, max_cards = $2
|
||||
WHERE id = $3
|
||||
`, req.WordsCount, req.MaxCards, configID)
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Создаем таблицу projects
|
||||
createProjectsTable := `
|
||||
@@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
t.repetition_date,
|
||||
t.progression_base,
|
||||
t.wishlist_id,
|
||||
t.config_id,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM tasks st
|
||||
@@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var repetitionDate sql.NullString
|
||||
var progressionBase sql.NullFloat64
|
||||
var wishlistID sql.NullInt64
|
||||
var configID sql.NullInt64
|
||||
var projectNames pq.StringArray
|
||||
var subtaskProjectNames pq.StringArray
|
||||
|
||||
@@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&repetitionDate,
|
||||
&progressionBase,
|
||||
&wishlistID,
|
||||
&configID,
|
||||
&task.SubtasksCount,
|
||||
&projectNames,
|
||||
&subtaskProjectNames,
|
||||
@@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
wishlistIDInt := int(wishlistID.Int64)
|
||||
task.WishlistID = &wishlistIDInt
|
||||
}
|
||||
if configID.Valid {
|
||||
configIDInt := int(configID.Int64)
|
||||
task.ConfigID = &configIDInt
|
||||
}
|
||||
|
||||
// Объединяем проекты из основной задачи и подзадач
|
||||
allProjects := make(map[string]bool)
|
||||
@@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var repetitionPeriod sql.NullString
|
||||
var repetitionDate sql.NullString
|
||||
var wishlistID sql.NullInt64
|
||||
var configID sql.NullInt64
|
||||
|
||||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||||
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,
|
||||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
||||
COALESCE(repetition_date, '') as repetition_date,
|
||||
wishlist_id
|
||||
wishlist_id,
|
||||
config_id
|
||||
FROM tasks
|
||||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||
`, 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)
|
||||
@@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
wishlistIDInt := int(wishlistID.Int64)
|
||||
task.WishlistID = &wishlistIDInt
|
||||
}
|
||||
if configID.Valid {
|
||||
configIDInt := int(configID.Int64)
|
||||
task.ConfigID = &configIDInt
|
||||
}
|
||||
|
||||
// Получаем награды основной задачи
|
||||
rewards := make([]Reward, 0)
|
||||
@@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
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 {
|
||||
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(¤tConfigID)
|
||||
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 {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
|
||||
@@ -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.';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.10.8",
|
||||
"version": "3.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -4,8 +4,7 @@ import FullStatistics from './components/FullStatistics'
|
||||
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||
import WordList from './components/WordList'
|
||||
import AddWords from './components/AddWords'
|
||||
import TestConfigSelection from './components/TestConfigSelection'
|
||||
import AddConfig from './components/AddConfig'
|
||||
import DictionaryList from './components/DictionaryList'
|
||||
import TestWords from './components/TestWords'
|
||||
import Profile from './components/Profile'
|
||||
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 mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile']
|
||||
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
|
||||
function AppContent() {
|
||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||
@@ -51,8 +50,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -71,8 +69,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -113,7 +110,7 @@ function AppContent() {
|
||||
// Состояние для кнопки Refresh (если она есть)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
||||
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||
|
||||
@@ -128,7 +125,7 @@ function AppContent() {
|
||||
// Проверяем URL только для глубоких табов
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
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)) {
|
||||
// Если в URL есть глубокий таб, восстанавливаем его
|
||||
@@ -381,8 +378,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -452,17 +448,17 @@ function AppContent() {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'test-config') {
|
||||
const isInitialized = tabsInitializedRef.current['test-config']
|
||||
} else if (tab === 'dictionaries') {
|
||||
const isInitialized = tabsInitializedRef.current['dictionaries']
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current['test-config'] = true
|
||||
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
||||
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current['dictionaries'] = true
|
||||
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
|
||||
} else if (isBackground) {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'tasks') {
|
||||
const hasCache = cacheRef.current.tasks !== null
|
||||
@@ -502,7 +498,7 @@ function AppContent() {
|
||||
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
||||
useEffect(() => {
|
||||
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 текущей записи истории (куда мы вернулись)
|
||||
if (event.state && event.state.tab) {
|
||||
@@ -617,15 +613,7 @@ function AppContent() {
|
||||
const isCurrentTabMain = mainTabs.includes(activeTab)
|
||||
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 может иметь taskId (редактирование) или wishlistId (создание из желания)
|
||||
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
||||
@@ -723,7 +711,7 @@ function AppContent() {
|
||||
}, [activeTab])
|
||||
|
||||
// Определяем, нужно ли скрывать нижнюю панель (для 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 = () => {
|
||||
@@ -818,21 +806,11 @@ function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['test-config'] && (
|
||||
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
||||
<TestConfigSelection
|
||||
{loadedTabs.dictionaries && (
|
||||
<div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
|
||||
<DictionaryList
|
||||
onNavigate={handleNavigate}
|
||||
refreshTrigger={testConfigRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['add-config'] && (
|
||||
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
||||
<AddConfig
|
||||
key={tabParams.config?.id || 'new'}
|
||||
onNavigate={handleNavigate}
|
||||
editingConfig={tabParams.config}
|
||||
refreshTrigger={dictionariesRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -844,6 +822,7 @@ function AppContent() {
|
||||
wordCount={tabParams.wordCount}
|
||||
configId={tabParams.configId}
|
||||
maxCards={tabParams.maxCards}
|
||||
taskId={tabParams.taskId}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</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
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,261 +1,31 @@
|
||||
.config-selection {
|
||||
.dictionary-list {
|
||||
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;
|
||||
}
|
||||
|
||||
.add-config-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
border-color: #2980b9;
|
||||
.dictionary-back-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: #2c3e50;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.add-config-icon {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
line-height: 1;
|
||||
.dictionary-back-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.add-config-text {
|
||||
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 {
|
||||
.dictionaries-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.dictionary-card {
|
||||
@@ -273,7 +43,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-selection .dictionary-card .card-menu-button {
|
||||
.dictionary-list .dictionary-card .dictionary-menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0;
|
||||
@@ -295,7 +65,7 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-selection .dictionary-card .card-menu-button:hover {
|
||||
.dictionary-list .dictionary-card .dictionary-menu-button:hover {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -347,11 +117,99 @@
|
||||
border-color: #1a252f;
|
||||
}
|
||||
|
||||
.add-dictionary-button .add-config-icon {
|
||||
.add-dictionary-icon {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
176
play-life-web/src/components/DictionaryList.jsx
Normal file
176
play-life-web/src/components/DictionaryList.jsx
Normal 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
|
||||
|
||||
@@ -35,6 +35,36 @@ function Profile({ onNavigate }) {
|
||||
</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 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
||||
|
||||
@@ -413,3 +413,99 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import './TaskForm.css'
|
||||
const API_URL = '/api/tasks'
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
|
||||
function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [progressionBase, setProgressionBase] = useState('')
|
||||
@@ -24,6 +24,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
||||
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)
|
||||
|
||||
// Загрузка проектов для автокомплита
|
||||
@@ -42,6 +48,22 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
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 = () => {
|
||||
setName('')
|
||||
@@ -54,6 +76,11 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
setSubtasks([])
|
||||
setError('')
|
||||
setLoadingTask(false)
|
||||
// Reset test-specific fields
|
||||
setIsTest(isTestFromProps)
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setSelectedDictionaryIDs([])
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = null
|
||||
@@ -316,6 +343,28 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
setCurrentWishlistId(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) {
|
||||
setError(err.message)
|
||||
} 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 = {
|
||||
name: name.trim(),
|
||||
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_date: repetitionDate,
|
||||
// При создании: отправляем currentWishlistId если указан (уже число)
|
||||
@@ -580,7 +644,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
value: parseFloat(r.value) || 0,
|
||||
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
|
||||
@@ -715,6 +784,7 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isTest && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="progression_base">Прогрессия</label>
|
||||
<input
|
||||
@@ -735,6 +805,67 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
|
||||
</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">
|
||||
<label htmlFor="repetition_period">Повторения</label>
|
||||
|
||||
@@ -512,3 +512,101 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
const [postponeDate, setPostponeDate] = useState('')
|
||||
const [isPostponing, setIsPostponing] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const dateInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,7 +37,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
const handleCheckmarkClick = async (task, e) => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -45,9 +55,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
}
|
||||
|
||||
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
|
||||
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
||||
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 hasSubtasks = task.subtasks_count > 0
|
||||
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, считаем бесконечной
|
||||
@@ -513,7 +536,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
<div
|
||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||
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">
|
||||
<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-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 && (
|
||||
<svg
|
||||
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 && (() => {
|
||||
const todayStr = formatDateToLocal(new Date())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,11 +8,12 @@ const API_URL = '/api'
|
||||
|
||||
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 wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||
const configId = initialConfigId || null
|
||||
const maxCards = initialMaxCards || null
|
||||
const taskId = initialTaskId || null
|
||||
|
||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||
@@ -366,6 +367,25 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
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) {
|
||||
console.error('Failed to save progress:', err)
|
||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||
@@ -537,7 +557,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate?.('test-config')
|
||||
onNavigate?.('tasks')
|
||||
}
|
||||
|
||||
const handleStartTest = () => {
|
||||
@@ -547,7 +567,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onNavigate?.('test-config')
|
||||
onNavigate?.('tasks')
|
||||
}
|
||||
|
||||
const getRandomSide = (word) => {
|
||||
|
||||
@@ -189,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
return (
|
||||
<div className="word-list">
|
||||
<button
|
||||
onClick={() => onNavigate?.('test-config')}
|
||||
onClick={() => onNavigate?.('dictionaries')}
|
||||
className="close-x-button"
|
||||
title="Закрыть"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user