Рефакторинг тестов: интеграция с задачами
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:
@@ -72,19 +72,15 @@ type TestProgressRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
WordsCount int `json:"words_count"`
|
||||||
WordsCount int `json:"words_count"`
|
MaxCards *int `json:"max_cards,omitempty"`
|
||||||
MaxCards *int `json:"max_cards,omitempty"`
|
|
||||||
TryMessage string `json:"try_message"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigRequest struct {
|
type ConfigRequest struct {
|
||||||
Name string `json:"name"`
|
WordsCount int `json:"words_count"`
|
||||||
WordsCount int `json:"words_count"`
|
MaxCards *int `json:"max_cards,omitempty"`
|
||||||
MaxCards *int `json:"max_cards,omitempty"`
|
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||||
TryMessage string `json:"try_message"`
|
|
||||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dictionary struct {
|
type Dictionary struct {
|
||||||
@@ -214,6 +210,7 @@ type Task struct {
|
|||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||||
|
ConfigID *int `json:"config_id,omitempty"`
|
||||||
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
||||||
ProjectNames []string `json:"project_names"`
|
ProjectNames []string `json:"project_names"`
|
||||||
SubtasksCount int `json:"subtasks_count"`
|
SubtasksCount int `json:"subtasks_count"`
|
||||||
@@ -234,9 +231,13 @@ type Subtask struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskDetail struct {
|
type TaskDetail struct {
|
||||||
Task Task `json:"task"`
|
Task Task `json:"task"`
|
||||||
Rewards []Reward `json:"rewards"`
|
Rewards []Reward `json:"rewards"`
|
||||||
Subtasks []Subtask `json:"subtasks"`
|
Subtasks []Subtask `json:"subtasks"`
|
||||||
|
// Test-specific fields (only present if task has config_id)
|
||||||
|
WordsCount *int `json:"words_count,omitempty"`
|
||||||
|
MaxCards *int `json:"max_cards,omitempty"`
|
||||||
|
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RewardRequest struct {
|
type RewardRequest struct {
|
||||||
@@ -262,6 +263,11 @@ type TaskRequest struct {
|
|||||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||||
Rewards []RewardRequest `json:"rewards,omitempty"`
|
Rewards []RewardRequest `json:"rewards,omitempty"`
|
||||||
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
||||||
|
// Test-specific fields
|
||||||
|
IsTest bool `json:"is_test,omitempty"`
|
||||||
|
WordsCount *int `json:"words_count,omitempty"`
|
||||||
|
MaxCards *int `json:"max_cards,omitempty"`
|
||||||
|
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompleteTaskRequest struct {
|
type CompleteTaskRequest struct {
|
||||||
@@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If config_id is provided, send webhook with try_message
|
// Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
|
||||||
if req.ConfigID != nil {
|
// The config_id is kept in the request for potential future use, but we no longer send messages here
|
||||||
configID := *req.ConfigID
|
// to avoid duplicate messages (one from test completion, one from task completion).
|
||||||
|
|
||||||
// Use mutex to prevent duplicate webhook sends
|
|
||||||
a.webhookMutex.Lock()
|
|
||||||
lastTime, exists := a.lastWebhookTime[configID]
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
|
|
||||||
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
|
|
||||||
|
|
||||||
if shouldSend {
|
|
||||||
a.lastWebhookTime[configID] = now
|
|
||||||
}
|
|
||||||
a.webhookMutex.Unlock()
|
|
||||||
|
|
||||||
if !shouldSend {
|
|
||||||
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
|
|
||||||
} else {
|
|
||||||
var tryMessage sql.NullString
|
|
||||||
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
|
|
||||||
if err == nil && tryMessage.Valid && tryMessage.String != "" {
|
|
||||||
// Process message directly (backend always runs together with frontend)
|
|
||||||
_, err := a.processMessage(tryMessage.String, &userID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error processing message: %v", err)
|
|
||||||
// Remove from map on error so it can be retried
|
|
||||||
a.webhookMutex.Lock()
|
|
||||||
delete(a.lastWebhookTime, configID)
|
|
||||||
a.webhookMutex.Unlock()
|
|
||||||
} else {
|
|
||||||
log.Printf("Message processed successfully for config_id %d", configID)
|
|
||||||
}
|
|
||||||
} else if err != nil && err != sql.ErrNoRows {
|
|
||||||
log.Printf("Error fetching config: %v", err)
|
|
||||||
} else if err == nil && (!tryMessage.Valid || tryMessage.String == "") {
|
|
||||||
log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
@@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, words_count, max_cards, try_message
|
SELECT id, words_count, max_cards
|
||||||
FROM configs
|
FROM configs
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var maxCards sql.NullInt64
|
var maxCards sql.NullInt64
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&config.ID,
|
&config.ID,
|
||||||
&config.Name,
|
|
||||||
&config.WordsCount,
|
&config.WordsCount,
|
||||||
&maxCards,
|
&maxCards,
|
||||||
&config.TryMessage,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
|||||||
|
|
||||||
// Get configs
|
// Get configs
|
||||||
configsQuery := `
|
configsQuery := `
|
||||||
SELECT id, name, words_count, max_cards, try_message
|
SELECT id, words_count, max_cards
|
||||||
FROM configs
|
FROM configs
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
|||||||
var maxCards sql.NullInt64
|
var maxCards sql.NullInt64
|
||||||
err := configsRows.Scan(
|
err := configsRows.Scan(
|
||||||
&config.ID,
|
&config.ID,
|
||||||
&config.Name,
|
|
||||||
&config.WordsCount,
|
&config.WordsCount,
|
||||||
&maxCards,
|
&maxCards,
|
||||||
&config.TryMessage,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.WordsCount <= 0 {
|
if req.WordsCount <= 0 {
|
||||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var id int
|
var id int
|
||||||
err = tx.QueryRow(`
|
err = tx.QueryRow(`
|
||||||
INSERT INTO configs (name, words_count, max_cards, try_message, user_id)
|
INSERT INTO configs (words_count, max_cards, user_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id)
|
`, req.WordsCount, req.MaxCards, userID).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.WordsCount <= 0 {
|
if req.WordsCount <= 0 {
|
||||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := tx.Exec(`
|
result, err := tx.Exec(`
|
||||||
UPDATE configs
|
UPDATE configs
|
||||||
SET name = $1, words_count = $2, max_cards = $3, try_message = $4
|
SET words_count = $1, max_cards = $2
|
||||||
WHERE id = $5
|
WHERE id = $3
|
||||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID)
|
`, req.WordsCount, req.MaxCards, configID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -2840,6 +2796,12 @@ func (a *App) initAuthDB() error {
|
|||||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply migration 022: Refactor configs to link with tasks
|
||||||
|
if err := a.applyMigration022(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to apply migration 022: %v", err)
|
||||||
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens (only those with expiration date set)
|
// Clean up expired refresh tokens (only those with expiration date set)
|
||||||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||||||
|
|
||||||
@@ -3109,6 +3071,51 @@ func (a *App) applyMigration021() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql
|
||||||
|
func (a *App) applyMigration022() error {
|
||||||
|
log.Printf("Applying migration 022: Refactor configs to link with tasks")
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже поле config_id в tasks
|
||||||
|
var exists bool
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tasks'
|
||||||
|
AND column_name = 'config_id'
|
||||||
|
)
|
||||||
|
`).Scan(&exists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check config_id column existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
log.Printf("Migration 022 already applied (config_id column exists), skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем SQL из файла миграции
|
||||||
|
migrationPath := "migrations/022_refactor_configs_to_tasks.sql"
|
||||||
|
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||||
|
// Пробуем альтернативный путь (в Docker)
|
||||||
|
migrationPath = "/migrations/022_refactor_configs_to_tasks.sql"
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationSQL, err := os.ReadFile(migrationPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем миграцию
|
||||||
|
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute migration 022: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Migration 022 applied successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) initPlayLifeDB() error {
|
func (a *App) initPlayLifeDB() error {
|
||||||
// Создаем таблицу projects
|
// Создаем таблицу projects
|
||||||
createProjectsTable := `
|
createProjectsTable := `
|
||||||
@@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.repetition_date,
|
t.repetition_date,
|
||||||
t.progression_base,
|
t.progression_base,
|
||||||
t.wishlist_id,
|
t.wishlist_id,
|
||||||
|
t.config_id,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM tasks st
|
FROM tasks st
|
||||||
@@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
var progressionBase sql.NullFloat64
|
var progressionBase sql.NullFloat64
|
||||||
var wishlistID sql.NullInt64
|
var wishlistID sql.NullInt64
|
||||||
|
var configID sql.NullInt64
|
||||||
var projectNames pq.StringArray
|
var projectNames pq.StringArray
|
||||||
var subtaskProjectNames pq.StringArray
|
var subtaskProjectNames pq.StringArray
|
||||||
|
|
||||||
@@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&repetitionDate,
|
&repetitionDate,
|
||||||
&progressionBase,
|
&progressionBase,
|
||||||
&wishlistID,
|
&wishlistID,
|
||||||
|
&configID,
|
||||||
&task.SubtasksCount,
|
&task.SubtasksCount,
|
||||||
&projectNames,
|
&projectNames,
|
||||||
&subtaskProjectNames,
|
&subtaskProjectNames,
|
||||||
@@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
wishlistIDInt := int(wishlistID.Int64)
|
wishlistIDInt := int(wishlistID.Int64)
|
||||||
task.WishlistID = &wishlistIDInt
|
task.WishlistID = &wishlistIDInt
|
||||||
}
|
}
|
||||||
|
if configID.Valid {
|
||||||
|
configIDInt := int(configID.Int64)
|
||||||
|
task.ConfigID = &configIDInt
|
||||||
|
}
|
||||||
|
|
||||||
// Объединяем проекты из основной задачи и подзадач
|
// Объединяем проекты из основной задачи и подзадач
|
||||||
allProjects := make(map[string]bool)
|
allProjects := make(map[string]bool)
|
||||||
@@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
var wishlistID sql.NullInt64
|
var wishlistID sql.NullInt64
|
||||||
|
var configID sql.NullInt64
|
||||||
|
|
||||||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||||||
var repetitionPeriodStr string
|
var repetitionPeriodStr string
|
||||||
@@ -6887,11 +6902,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
||||||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
||||||
COALESCE(repetition_date, '') as repetition_date,
|
COALESCE(repetition_date, '') as repetition_date,
|
||||||
wishlist_id
|
wishlist_id,
|
||||||
|
config_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||||
`, taskID, userID).Scan(
|
`, taskID, userID).Scan(
|
||||||
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID,
|
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
||||||
@@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
wishlistIDInt := int(wishlistID.Int64)
|
wishlistIDInt := int(wishlistID.Int64)
|
||||||
task.WishlistID = &wishlistIDInt
|
task.WishlistID = &wishlistIDInt
|
||||||
}
|
}
|
||||||
|
if configID.Valid {
|
||||||
|
configIDInt := int(configID.Int64)
|
||||||
|
task.ConfigID = &configIDInt
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем награды основной задачи
|
// Получаем награды основной задачи
|
||||||
rewards := make([]Reward, 0)
|
rewards := make([]Reward, 0)
|
||||||
@@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Subtasks: subtasks,
|
Subtasks: subtasks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если задача - тест (есть config_id), загружаем данные конфигурации
|
||||||
|
if configID.Valid {
|
||||||
|
var wordsCount int
|
||||||
|
var maxCards sql.NullInt64
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT words_count, max_cards
|
||||||
|
FROM configs
|
||||||
|
WHERE id = $1
|
||||||
|
`, configID.Int64).Scan(&wordsCount, &maxCards)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
response.WordsCount = &wordsCount
|
||||||
|
if maxCards.Valid {
|
||||||
|
maxCardsInt := int(maxCards.Int64)
|
||||||
|
response.MaxCards = &maxCardsInt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем связанные словари
|
||||||
|
dictRows, err := a.DB.Query(`
|
||||||
|
SELECT dictionary_id
|
||||||
|
FROM config_dictionaries
|
||||||
|
WHERE config_id = $1
|
||||||
|
`, configID.Int64)
|
||||||
|
if err == nil {
|
||||||
|
defer dictRows.Close()
|
||||||
|
dictionaryIDs := make([]int, 0)
|
||||||
|
for dictRows.Next() {
|
||||||
|
var dictID int
|
||||||
|
if err := dictRows.Scan(&dictID); err == nil {
|
||||||
|
dictionaryIDs = append(dictionaryIDs, dictID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dictionaryIDs) > 0 {
|
||||||
|
response.DictionaryIDs = dictionaryIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Error loading config for task %d: %v", taskID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
@@ -7365,6 +7426,66 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если это тест, создаем конфигурацию
|
||||||
|
if req.IsTest {
|
||||||
|
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
|
||||||
|
if req.WordsCount == nil || *req.WordsCount < 1 {
|
||||||
|
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.DictionaryIDs) == 0 {
|
||||||
|
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем конфигурацию теста
|
||||||
|
var configID int
|
||||||
|
if req.MaxCards != nil {
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO configs (user_id, words_count, max_cards)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
`, userID, *req.WordsCount, *req.MaxCards).Scan(&configID)
|
||||||
|
} else {
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO configs (user_id, words_count)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id
|
||||||
|
`, userID, *req.WordsCount).Scan(&configID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating config: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Связываем конфигурацию со словарями
|
||||||
|
for _, dictID := range req.DictionaryIDs {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
`, configID, dictID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем задачу, привязывая config_id
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE tasks SET config_id = $1 WHERE id = $2
|
||||||
|
`, configID, taskID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error linking config to task: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Created test config %d for task %d", configID, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
// Коммитим транзакцию
|
// Коммитим транзакцию
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Printf("Error committing transaction: %v", err)
|
log.Printf("Error committing transaction: %v", err)
|
||||||
@@ -7771,6 +7892,116 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем текущий config_id задачи
|
||||||
|
var currentConfigID sql.NullInt64
|
||||||
|
err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(¤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 {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Printf("Error committing transaction: %v", err)
|
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",
|
"name": "play-life-web",
|
||||||
"version": "3.10.8",
|
"version": "3.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import FullStatistics from './components/FullStatistics'
|
|||||||
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||||
import WordList from './components/WordList'
|
import WordList from './components/WordList'
|
||||||
import AddWords from './components/AddWords'
|
import AddWords from './components/AddWords'
|
||||||
import TestConfigSelection from './components/TestConfigSelection'
|
import DictionaryList from './components/DictionaryList'
|
||||||
import AddConfig from './components/AddConfig'
|
|
||||||
import TestWords from './components/TestWords'
|
import TestWords from './components/TestWords'
|
||||||
import Profile from './components/Profile'
|
import Profile from './components/Profile'
|
||||||
import TaskList from './components/TaskList'
|
import TaskList from './components/TaskList'
|
||||||
@@ -24,8 +23,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
|
|||||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||||
|
|
||||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||||
const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile']
|
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||||
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
@@ -51,8 +50,7 @@ function AppContent() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
'task-form': false,
|
'task-form': false,
|
||||||
@@ -71,8 +69,7 @@ function AppContent() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
'task-form': false,
|
'task-form': false,
|
||||||
@@ -113,7 +110,7 @@ function AppContent() {
|
|||||||
// Состояние для кнопки Refresh (если она есть)
|
// Состояние для кнопки Refresh (если она есть)
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
||||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||||
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
@@ -128,7 +125,7 @@ function AppContent() {
|
|||||||
// Проверяем URL только для глубоких табов
|
// Проверяем URL только для глубоких табов
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const tabFromUrl = urlParams.get('tab')
|
const tabFromUrl = urlParams.get('tab')
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||||
|
|
||||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
// Если в URL есть глубокий таб, восстанавливаем его
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
@@ -381,8 +378,7 @@ function AppContent() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
tasks: false,
|
tasks: false,
|
||||||
'task-form': false,
|
'task-form': false,
|
||||||
@@ -452,17 +448,17 @@ function AppContent() {
|
|||||||
// Возврат на таб - фоновая загрузка
|
// Возврат на таб - фоновая загрузка
|
||||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
} else if (tab === 'test-config') {
|
} else if (tab === 'dictionaries') {
|
||||||
const isInitialized = tabsInitializedRef.current['test-config']
|
const isInitialized = tabsInitializedRef.current['dictionaries']
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// Первая загрузка таба
|
// Первая загрузка таба
|
||||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||||
tabsInitializedRef.current['test-config'] = true
|
tabsInitializedRef.current['dictionaries'] = true
|
||||||
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
|
||||||
} else if (isBackground) {
|
} else if (isBackground) {
|
||||||
// Возврат на таб - фоновая загрузка
|
// Возврат на таб - фоновая загрузка
|
||||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
} else if (tab === 'tasks') {
|
} else if (tab === 'tasks') {
|
||||||
const hasCache = cacheRef.current.tasks !== null
|
const hasCache = cacheRef.current.tasks !== null
|
||||||
@@ -502,7 +498,7 @@ function AppContent() {
|
|||||||
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = (event) => {
|
const handlePopState = (event) => {
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||||
|
|
||||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
@@ -617,15 +613,7 @@ function AppContent() {
|
|||||||
const isCurrentTabMain = mainTabs.includes(activeTab)
|
const isCurrentTabMain = mainTabs.includes(activeTab)
|
||||||
const isNewTabMain = mainTabs.includes(tab)
|
const isNewTabMain = mainTabs.includes(tab)
|
||||||
|
|
||||||
// Сбрасываем tabParams при переходе с add-config на другой таб
|
{
|
||||||
if (activeTab === 'add-config' && tab !== 'add-config') {
|
|
||||||
setTabParams({})
|
|
||||||
if (isNewTabMain) {
|
|
||||||
clearUrl()
|
|
||||||
} else if (isNewTabDeep) {
|
|
||||||
updateUrl(tab, {}, activeTab)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||||
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
|
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
|
||||||
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
||||||
@@ -723,7 +711,7 @@ function AppContent() {
|
|||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'dictionaries'
|
||||||
|
|
||||||
// Определяем отступы для контейнера
|
// Определяем отступы для контейнера
|
||||||
const getContainerPadding = () => {
|
const getContainerPadding = () => {
|
||||||
@@ -818,21 +806,11 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadedTabs['test-config'] && (
|
{loadedTabs.dictionaries && (
|
||||||
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
|
||||||
<TestConfigSelection
|
<DictionaryList
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
refreshTrigger={testConfigRefreshTrigger}
|
refreshTrigger={dictionariesRefreshTrigger}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadedTabs['add-config'] && (
|
|
||||||
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
|
||||||
<AddConfig
|
|
||||||
key={tabParams.config?.id || 'new'}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
editingConfig={tabParams.config}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -844,6 +822,7 @@ function AppContent() {
|
|||||||
wordCount={tabParams.wordCount}
|
wordCount={tabParams.wordCount}
|
||||||
configId={tabParams.configId}
|
configId={tabParams.configId}
|
||||||
maxCards={tabParams.maxCards}
|
maxCards={tabParams.maxCards}
|
||||||
|
taskId={tabParams.taskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -948,27 +927,6 @@ function AppContent() {
|
|||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleTabChange('test-config')}
|
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
|
||||||
activeTab === 'test-config' || activeTab === 'test'
|
|
||||||
? 'text-indigo-700 bg-white/50'
|
|
||||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
|
||||||
}`}
|
|
||||||
title="Тест"
|
|
||||||
>
|
|
||||||
<span className="relative z-10 flex items-center justify-center">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
|
||||||
<path d="M8 7h6"></path>
|
|
||||||
<path d="M8 11h4"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{(activeTab === 'test-config' || activeTab === 'test') && (
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('tasks')}
|
onClick={() => handleTabChange('tasks')}
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
|||||||
@@ -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;
|
padding-top: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.add-config-button {
|
|
||||||
background: transparent;
|
|
||||||
border: 2px dashed #3498db;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 180px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-config-button:hover {
|
.dictionary-back-button {
|
||||||
transform: translateY(-2px);
|
position: absolute;
|
||||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
top: 0;
|
||||||
background-color: rgba(52, 152, 219, 0.05);
|
left: 0;
|
||||||
border-color: #2980b9;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2c3e50;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-config-icon {
|
.dictionary-back-button:hover {
|
||||||
font-size: 3rem;
|
background: rgba(0, 0, 0, 0.05);
|
||||||
font-weight: bold;
|
|
||||||
color: #3498db;
|
|
||||||
margin-bottom: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-config-text {
|
.dictionaries-grid {
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #3498db;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading, .error-message {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #e74c3c;
|
|
||||||
background-color: #f8d7da;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configs-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
padding-top: 2.5rem;
|
||||||
|
|
||||||
.config-card {
|
|
||||||
background: #3498db;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 180px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-selection .config-card .card-menu-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0;
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
width: 40px !important;
|
|
||||||
height: 40px !important;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.5rem !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: all 0.2s;
|
|
||||||
z-index: 10;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-selection .config-card .card-menu-button:hover {
|
|
||||||
background: transparent !important;
|
|
||||||
opacity: 0.7;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-words-count {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-max-cards {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: -1rem;
|
|
||||||
margin-bottom: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: modalSlideIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalSlideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(-20px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.config-modal-close:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-edit,
|
|
||||||
.config-modal-delete {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-edit {
|
|
||||||
background-color: #3498db;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-edit:hover {
|
|
||||||
background-color: #2980b9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-delete {
|
|
||||||
background-color: #e74c3c;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-delete:hover {
|
|
||||||
background-color: #c0392b;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider {
|
|
||||||
margin: 0.5rem 0 1rem 0;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictionaries-section {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-card {
|
.dictionary-card {
|
||||||
@@ -273,7 +43,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-selection .dictionary-card .card-menu-button {
|
.dictionary-list .dictionary-card .dictionary-menu-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -295,7 +65,7 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-selection .dictionary-card .card-menu-button:hover {
|
.dictionary-list .dictionary-card .dictionary-menu-button:hover {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
@@ -347,11 +117,99 @@
|
|||||||
border-color: #1a252f;
|
border-color: #1a252f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-dictionary-button .add-config-icon {
|
.add-dictionary-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
margin-bottom: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-dictionary-button .add-config-text {
|
.add-dictionary-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.dictionary-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: dictionaryModalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dictionaryModalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-delete {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-delete:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Функционал</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('dictionaries')}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||||
|
Словари
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Integrations Section */}
|
{/* Integrations Section */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
||||||
|
|||||||
@@ -413,3 +413,99 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Test configuration styles */
|
||||||
|
.test-config-section {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config-section > label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-field-group label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-section > label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-no-dictionaries {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './TaskForm.css'
|
|||||||
const API_URL = '/api/tasks'
|
const API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
|
|
||||||
function TaskForm({ onNavigate, taskId, wishlistId }) {
|
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [progressionBase, setProgressionBase] = useState('')
|
const [progressionBase, setProgressionBase] = useState('')
|
||||||
@@ -24,6 +24,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
||||||
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
|
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
|
||||||
|
// Test-specific state
|
||||||
|
const [isTest, setIsTest] = useState(isTestFromProps)
|
||||||
|
const [wordsCount, setWordsCount] = useState('10')
|
||||||
|
const [maxCards, setMaxCards] = useState('')
|
||||||
|
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
|
||||||
|
const [availableDictionaries, setAvailableDictionaries] = useState([])
|
||||||
const debounceTimer = useRef(null)
|
const debounceTimer = useRef(null)
|
||||||
|
|
||||||
// Загрузка проектов для автокомплита
|
// Загрузка проектов для автокомплита
|
||||||
@@ -42,6 +48,22 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
loadProjects()
|
loadProjects()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Загрузка словарей для тестов
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDictionaries = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/test-configs-and-dictionaries')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setAvailableDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading dictionaries:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDictionaries()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Функция сброса формы
|
// Функция сброса формы
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('')
|
setName('')
|
||||||
@@ -54,6 +76,11 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
setSubtasks([])
|
setSubtasks([])
|
||||||
setError('')
|
setError('')
|
||||||
setLoadingTask(false)
|
setLoadingTask(false)
|
||||||
|
// Reset test-specific fields
|
||||||
|
setIsTest(isTestFromProps)
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
setSelectedDictionaryIDs([])
|
||||||
if (debounceTimer.current) {
|
if (debounceTimer.current) {
|
||||||
clearTimeout(debounceTimer.current)
|
clearTimeout(debounceTimer.current)
|
||||||
debounceTimer.current = null
|
debounceTimer.current = null
|
||||||
@@ -316,6 +343,28 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
setCurrentWishlistId(null)
|
setCurrentWishlistId(null)
|
||||||
setWishlistInfo(null)
|
setWishlistInfo(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загружаем информацию о тесте, если есть config_id
|
||||||
|
if (data.task.config_id) {
|
||||||
|
setIsTest(true)
|
||||||
|
// Данные теста приходят прямо в ответе getTaskDetail
|
||||||
|
if (data.words_count) {
|
||||||
|
setWordsCount(String(data.words_count))
|
||||||
|
}
|
||||||
|
if (data.max_cards) {
|
||||||
|
setMaxCards(String(data.max_cards))
|
||||||
|
}
|
||||||
|
if (data.dictionary_ids && Array.isArray(data.dictionary_ids)) {
|
||||||
|
setSelectedDictionaryIDs(data.dictionary_ids)
|
||||||
|
}
|
||||||
|
// Тесты не могут иметь прогрессию
|
||||||
|
setProgressionBase('')
|
||||||
|
} else {
|
||||||
|
setIsTest(false)
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
setSelectedDictionaryIDs([])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -551,11 +600,26 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Валидация для тестов
|
||||||
|
if (isTest) {
|
||||||
|
const wordsCountNum = parseInt(wordsCount, 10)
|
||||||
|
if (isNaN(wordsCountNum) || wordsCountNum < 1) {
|
||||||
|
setError('Количество слов должно быть минимум 1')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedDictionaryIDs.length === 0) {
|
||||||
|
setError('Выберите хотя бы один словарь')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
reward_message: rewardMessage.trim() || null,
|
reward_message: rewardMessage.trim() || null,
|
||||||
// Если задача привязана к желанию, не отправляем progression_base
|
// Тесты и задачи с желанием не могут иметь прогрессию
|
||||||
progression_base: isLinkedToWishlist ? null : (progressionBase ? parseFloat(progressionBase) : null),
|
progression_base: (isLinkedToWishlist || isTest) ? null : (progressionBase ? parseFloat(progressionBase) : null),
|
||||||
repetition_period: repetitionPeriod,
|
repetition_period: repetitionPeriod,
|
||||||
repetition_date: repetitionDate,
|
repetition_date: repetitionDate,
|
||||||
// При создании: отправляем currentWishlistId если указан (уже число)
|
// При создании: отправляем currentWishlistId если указан (уже число)
|
||||||
@@ -580,7 +644,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
value: parseFloat(r.value) || 0,
|
value: parseFloat(r.value) || 0,
|
||||||
use_progression: !!(progressionBase && r.use_progression)
|
use_progression: !!(progressionBase && r.use_progression)
|
||||||
}))
|
}))
|
||||||
}))
|
})),
|
||||||
|
// Test-specific fields
|
||||||
|
is_test: isTest,
|
||||||
|
words_count: isTest ? parseInt(wordsCount, 10) : undefined,
|
||||||
|
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
|
||||||
|
dictionary_ids: isTest ? selectedDictionaryIDs : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = taskId ? `${API_URL}/${taskId}` : API_URL
|
const url = taskId ? `${API_URL}/${taskId}` : API_URL
|
||||||
@@ -715,26 +784,88 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
{!isTest && (
|
||||||
<label htmlFor="progression_base">Прогрессия</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="progression_base">Прогрессия</label>
|
||||||
id="progression_base"
|
<input
|
||||||
type="number"
|
id="progression_base"
|
||||||
step="any"
|
type="number"
|
||||||
value={progressionBase}
|
step="any"
|
||||||
onChange={(e) => {
|
value={progressionBase}
|
||||||
if (!wishlistInfo) {
|
onChange={(e) => {
|
||||||
setProgressionBase(e.target.value)
|
if (!wishlistInfo) {
|
||||||
}
|
setProgressionBase(e.target.value)
|
||||||
}}
|
}
|
||||||
placeholder="Базовое значение"
|
}}
|
||||||
className="form-input"
|
placeholder="Базовое значение"
|
||||||
disabled={wishlistInfo !== null}
|
className="form-input"
|
||||||
/>
|
disabled={wishlistInfo !== null}
|
||||||
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}>
|
/>
|
||||||
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
|
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}>
|
||||||
</small>
|
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
|
||||||
</div>
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test-specific fields */}
|
||||||
|
{isTest && (
|
||||||
|
<div className="form-group test-config-section">
|
||||||
|
<label>Настройки теста</label>
|
||||||
|
<div className="test-config-fields">
|
||||||
|
<div className="test-field-group">
|
||||||
|
<label htmlFor="words_count">Количество слов *</label>
|
||||||
|
<input
|
||||||
|
id="words_count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={wordsCount}
|
||||||
|
onChange={(e) => setWordsCount(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="test-field-group">
|
||||||
|
<label htmlFor="max_cards">Макс. карточек</label>
|
||||||
|
<input
|
||||||
|
id="max_cards"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={maxCards}
|
||||||
|
onChange={(e) => setMaxCards(e.target.value)}
|
||||||
|
placeholder="Без ограничения"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="test-dictionaries-section">
|
||||||
|
<label>Словари *</label>
|
||||||
|
<div className="test-dictionaries-list">
|
||||||
|
{availableDictionaries.map(dict => (
|
||||||
|
<label key={dict.id} className="test-dictionary-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDictionaryIDs.includes(dict.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedDictionaryIDs([...selectedDictionaryIDs, dict.id])
|
||||||
|
} else {
|
||||||
|
setSelectedDictionaryIDs(selectedDictionaryIDs.filter(id => id !== dict.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="test-dictionary-name">{dict.name}</span>
|
||||||
|
<span className="test-dictionary-count">({dict.wordsCount} слов)</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{availableDictionaries.length === 0 && (
|
||||||
|
<div className="test-no-dictionaries">
|
||||||
|
Нет доступных словарей. Создайте словарь в разделе "Словари".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="repetition_period">Повторения</label>
|
<label htmlFor="repetition_period">Повторения</label>
|
||||||
|
|||||||
@@ -512,3 +512,101 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badge icons for test and wishlist tasks */
|
||||||
|
.task-test-icon {
|
||||||
|
color: #3498db;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-icon {
|
||||||
|
color: #e74c3c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add task/test modal */
|
||||||
|
.task-add-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
animation: modalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-header {
|
||||||
|
padding: 1.25rem 1.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-buttons {
|
||||||
|
padding: 0 1.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-task {
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-task:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-test {
|
||||||
|
background: linear-gradient(to right, #3498db, #2980b9);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-test:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
const [postponeDate, setPostponeDate] = useState('')
|
const [postponeDate, setPostponeDate] = useState('')
|
||||||
const [isPostponing, setIsPostponing] = useState(false)
|
const [isPostponing, setIsPostponing] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const dateInputRef = useRef(null)
|
const dateInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,7 +37,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
const handleCheckmarkClick = async (task, e) => {
|
const handleCheckmarkClick = async (task, e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
// Всегда открываем диалог подтверждения
|
// Для задач-тестов запускаем тест вместо открытия модального окна
|
||||||
|
const isTest = task.config_id != null
|
||||||
|
if (isTest) {
|
||||||
|
if (task.config_id) {
|
||||||
|
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для обычных задач открываем диалог подтверждения
|
||||||
setSelectedTaskForDetail(task.id)
|
setSelectedTaskForDetail(task.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +55,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddClick = () => {
|
const handleAddClick = () => {
|
||||||
onNavigate?.('task-form', { taskId: undefined })
|
setShowAddModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
setShowAddModal(false)
|
||||||
|
onNavigate?.('task-form', { taskId: undefined, isTest: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTest = () => {
|
||||||
|
setShowAddModal(false)
|
||||||
|
onNavigate?.('task-form', { taskId: undefined, isTest: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Функция для вычисления следующей даты по repetition_date
|
// Функция для вычисления следующей даты по repetition_date
|
||||||
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
||||||
if (!repetitionDateStr) return null
|
if (!repetitionDateStr) return null
|
||||||
@@ -490,6 +511,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
const hasProgression = task.has_progression || task.progression_base != null
|
const hasProgression = task.has_progression || task.progression_base != null
|
||||||
const hasSubtasks = task.subtasks_count > 0
|
const hasSubtasks = task.subtasks_count > 0
|
||||||
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
||||||
|
const isTest = task.config_id != null
|
||||||
|
const isWishlist = task.wishlist_id != null
|
||||||
|
|
||||||
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
|
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
|
||||||
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
|
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
|
||||||
@@ -513,7 +536,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
<div
|
<div
|
||||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||||
onClick={(e) => handleCheckmarkClick(task, e)}
|
onClick={(e) => handleCheckmarkClick(task, e)}
|
||||||
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
|
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||||
@@ -528,6 +551,43 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
||||||
)}
|
)}
|
||||||
<span className="task-badge-bar">
|
<span className="task-badge-bar">
|
||||||
|
{isWishlist && (
|
||||||
|
<svg
|
||||||
|
className="task-wishlist-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Связано с желанием"
|
||||||
|
>
|
||||||
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isTest && (
|
||||||
|
<svg
|
||||||
|
className="task-test-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Тест"
|
||||||
|
>
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{hasProgression && (
|
{hasProgression && (
|
||||||
<svg
|
<svg
|
||||||
className="task-progression-icon"
|
className="task-progression-icon"
|
||||||
@@ -741,6 +801,41 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно выбора типа задачи */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="task-add-modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||||
|
<div className="task-add-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-add-modal-header">
|
||||||
|
<h3>Что добавить?</h3>
|
||||||
|
</div>
|
||||||
|
<div className="task-add-modal-buttons">
|
||||||
|
<button
|
||||||
|
className="task-add-modal-button task-add-modal-button-task"
|
||||||
|
onClick={handleAddTask}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 11l3 3L22 4"></path>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
Задача
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="task-add-modal-button task-add-modal-button-test"
|
||||||
|
onClick={handleAddTest}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||||
|
<path d="M8 7h6"></path>
|
||||||
|
<path d="M8 11h4"></path>
|
||||||
|
</svg>
|
||||||
|
Тест
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Модальное окно для переноса задачи */}
|
{/* Модальное окно для переноса задачи */}
|
||||||
{selectedTaskForPostpone && (() => {
|
{selectedTaskForPostpone && (() => {
|
||||||
const todayStr = formatDateToLocal(new Date())
|
const todayStr = formatDateToLocal(new Date())
|
||||||
|
|||||||
@@ -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
|
const DEFAULT_TEST_WORD_COUNT = 10
|
||||||
|
|
||||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||||
const configId = initialConfigId || null
|
const configId = initialConfigId || null
|
||||||
const maxCards = initialMaxCards || null
|
const maxCards = initialMaxCards || null
|
||||||
|
const taskId = initialTaskId || null
|
||||||
|
|
||||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||||
@@ -366,6 +367,25 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
const responseData = await response.json().catch(() => ({}))
|
const responseData = await response.json().catch(() => ({}))
|
||||||
console.log('Test progress saved successfully:', responseData)
|
console.log('Test progress saved successfully:', responseData)
|
||||||
|
|
||||||
|
// Если есть taskId, выполняем задачу
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completeResponse.ok) {
|
||||||
|
console.log('Task completed successfully')
|
||||||
|
} else {
|
||||||
|
console.error('Failed to complete task:', await completeResponse.text())
|
||||||
|
}
|
||||||
|
} catch (taskErr) {
|
||||||
|
console.error('Failed to complete task:', taskErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save progress:', err)
|
console.error('Failed to save progress:', err)
|
||||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||||
@@ -537,7 +557,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onNavigate?.('test-config')
|
onNavigate?.('tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStartTest = () => {
|
const handleStartTest = () => {
|
||||||
@@ -547,7 +567,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFinish = () => {
|
const handleFinish = () => {
|
||||||
onNavigate?.('test-config')
|
onNavigate?.('tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRandomSide = (word) => {
|
const getRandomSide = (word) => {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
return (
|
return (
|
||||||
<div className="word-list">
|
<div className="word-list">
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('test-config')}
|
onClick={() => onNavigate?.('dictionaries')}
|
||||||
className="close-x-button"
|
className="close-x-button"
|
||||||
title="Закрыть"
|
title="Закрыть"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user