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

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

View File

@@ -1 +1 @@
3.10.8
3.11.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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