Рефакторинг тестов: интеграция с задачами
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.10.8",
|
||||
"version": "3.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -4,8 +4,7 @@ import FullStatistics from './components/FullStatistics'
|
||||
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||
import WordList from './components/WordList'
|
||||
import AddWords from './components/AddWords'
|
||||
import TestConfigSelection from './components/TestConfigSelection'
|
||||
import AddConfig from './components/AddConfig'
|
||||
import DictionaryList from './components/DictionaryList'
|
||||
import TestWords from './components/TestWords'
|
||||
import Profile from './components/Profile'
|
||||
import TaskList from './components/TaskList'
|
||||
@@ -24,8 +23,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||
|
||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||
const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile']
|
||||
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
|
||||
function AppContent() {
|
||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||
@@ -51,8 +50,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -71,8 +69,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -113,7 +110,7 @@ function AppContent() {
|
||||
// Состояние для кнопки Refresh (если она есть)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
||||
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||
|
||||
@@ -128,7 +125,7 @@ function AppContent() {
|
||||
// Проверяем URL только для глубоких табов
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tabFromUrl = urlParams.get('tab')
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
|
||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||
// Если в URL есть глубокий таб, восстанавливаем его
|
||||
@@ -381,8 +378,7 @@ function AppContent() {
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
dictionaries: false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
@@ -452,17 +448,17 @@ function AppContent() {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'test-config') {
|
||||
const isInitialized = tabsInitializedRef.current['test-config']
|
||||
} else if (tab === 'dictionaries') {
|
||||
const isInitialized = tabsInitializedRef.current['dictionaries']
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current['test-config'] = true
|
||||
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
||||
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current['dictionaries'] = true
|
||||
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
|
||||
} else if (isBackground) {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'tasks') {
|
||||
const hasCache = cacheRef.current.tasks !== null
|
||||
@@ -502,7 +498,7 @@ function AppContent() {
|
||||
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
||||
useEffect(() => {
|
||||
const handlePopState = (event) => {
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
|
||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||
if (event.state && event.state.tab) {
|
||||
@@ -617,15 +613,7 @@ function AppContent() {
|
||||
const isCurrentTabMain = mainTabs.includes(activeTab)
|
||||
const isNewTabMain = mainTabs.includes(tab)
|
||||
|
||||
// Сбрасываем tabParams при переходе с add-config на другой таб
|
||||
if (activeTab === 'add-config' && tab !== 'add-config') {
|
||||
setTabParams({})
|
||||
if (isNewTabMain) {
|
||||
clearUrl()
|
||||
} else if (isNewTabDeep) {
|
||||
updateUrl(tab, {}, activeTab)
|
||||
}
|
||||
} else {
|
||||
{
|
||||
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
|
||||
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
||||
@@ -723,7 +711,7 @@ function AppContent() {
|
||||
}, [activeTab])
|
||||
|
||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'dictionaries'
|
||||
|
||||
// Определяем отступы для контейнера
|
||||
const getContainerPadding = () => {
|
||||
@@ -818,21 +806,11 @@ function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['test-config'] && (
|
||||
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
||||
<TestConfigSelection
|
||||
{loadedTabs.dictionaries && (
|
||||
<div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
|
||||
<DictionaryList
|
||||
onNavigate={handleNavigate}
|
||||
refreshTrigger={testConfigRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['add-config'] && (
|
||||
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
||||
<AddConfig
|
||||
key={tabParams.config?.id || 'new'}
|
||||
onNavigate={handleNavigate}
|
||||
editingConfig={tabParams.config}
|
||||
refreshTrigger={dictionariesRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -844,6 +822,7 @@ function AppContent() {
|
||||
wordCount={tabParams.wordCount}
|
||||
configId={tabParams.configId}
|
||||
maxCards={tabParams.maxCards}
|
||||
taskId={tabParams.taskId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -948,27 +927,6 @@ function AppContent() {
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('test-config')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
activeTab === 'test-config' || activeTab === 'test'
|
||||
? 'text-indigo-700 bg-white/50'
|
||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Тест"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
<path d="M8 7h6"></path>
|
||||
<path d="M8 11h4"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{(activeTab === 'test-config' || activeTab === 'test') && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
.add-config {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.add-config {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-config h2 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #2c3e50;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.stepper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepper-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepper-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.stepper-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stepper-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stepper-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dictionaries-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dictionaries-checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label:hover {
|
||||
background-color: #e8f4f8;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
margin: 0;
|
||||
margin-right: 0.75rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #3498db;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label span {
|
||||
color: #2c3e50;
|
||||
font-size: 0.95rem;
|
||||
line-height: 18px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './AddConfig.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [tryMessage, setTryMessage] = useState('')
|
||||
const [wordsCount, setWordsCount] = useState('10')
|
||||
const [maxCards, setMaxCards] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
|
||||
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
|
||||
|
||||
// Load dictionaries
|
||||
useEffect(() => {
|
||||
const loadDictionaries = async () => {
|
||||
setLoadingDictionaries(true)
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
const data = await response.json()
|
||||
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load dictionaries:', err)
|
||||
} finally {
|
||||
setLoadingDictionaries(false)
|
||||
}
|
||||
}
|
||||
loadDictionaries()
|
||||
}, [])
|
||||
|
||||
// Load selected dictionaries when editing
|
||||
useEffect(() => {
|
||||
const loadSelectedDictionaries = async () => {
|
||||
if (initialEditingConfig?.id) {
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load selected dictionaries:', err)
|
||||
}
|
||||
} else {
|
||||
setSelectedDictionaryIds([])
|
||||
}
|
||||
}
|
||||
loadSelectedDictionaries()
|
||||
}, [initialEditingConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEditingConfig) {
|
||||
setName(initialEditingConfig.name)
|
||||
setTryMessage(initialEditingConfig.try_message)
|
||||
setWordsCount(String(initialEditingConfig.words_count))
|
||||
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
|
||||
} else {
|
||||
// Сбрасываем состояние при открытии в режиме добавления
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
setSelectedDictionaryIds([])
|
||||
}
|
||||
}, [initialEditingConfig])
|
||||
|
||||
// Сбрасываем состояние при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage('')
|
||||
setLoading(true)
|
||||
|
||||
if (!name.trim()) {
|
||||
setMessage('Имя обязательно для заполнения.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = initialEditingConfig
|
||||
? `${API_URL}/configs/${initialEditingConfig.id}`
|
||||
: `${API_URL}/configs`
|
||||
const method = initialEditingConfig ? 'PUT' : 'POST'
|
||||
|
||||
const response = await authFetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
try_message: tryMessage.trim() || '',
|
||||
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
|
||||
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
|
||||
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (!initialEditingConfig) {
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
}
|
||||
|
||||
// Navigate back immediately
|
||||
onNavigate?.('test-config')
|
||||
} catch (error) {
|
||||
setMessage(`Ошибка: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getNumericValue = () => {
|
||||
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
|
||||
}
|
||||
|
||||
const getMaxCardsNumericValue = () => {
|
||||
return maxCards === '' ? 0 : parseInt(maxCards) || 0
|
||||
}
|
||||
|
||||
const handleDecrease = () => {
|
||||
const numValue = getNumericValue()
|
||||
if (numValue > 0) {
|
||||
setWordsCount(String(numValue - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIncrease = () => {
|
||||
const numValue = getNumericValue()
|
||||
setWordsCount(String(numValue + 1))
|
||||
}
|
||||
|
||||
const handleMaxCardsDecrease = () => {
|
||||
const numValue = getMaxCardsNumericValue()
|
||||
if (numValue > 0) {
|
||||
setMaxCards(String(numValue - 1))
|
||||
} else {
|
||||
setMaxCards('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaxCardsIncrease = () => {
|
||||
const numValue = getMaxCardsNumericValue()
|
||||
const newValue = numValue + 1
|
||||
setMaxCards(String(newValue))
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// Сбрасываем состояние при закрытии
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-config">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
<h2>Конфигурация теста</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Имя</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Название конфига"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
|
||||
<textarea
|
||||
id="tryMessage"
|
||||
className="form-textarea"
|
||||
value={tryMessage}
|
||||
onChange={(e) => setTryMessage(e.target.value)}
|
||||
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="wordsCount">Кол-во слов</label>
|
||||
<div className="stepper-container">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleDecrease}
|
||||
disabled={getNumericValue() <= 0}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
id="wordsCount"
|
||||
type="number"
|
||||
className="stepper-input"
|
||||
value={wordsCount}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value
|
||||
if (inputValue === '') {
|
||||
setWordsCount('')
|
||||
} else {
|
||||
const numValue = parseInt(inputValue)
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setWordsCount(inputValue)
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleIncrease}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
|
||||
<div className="stepper-container">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleMaxCardsDecrease}
|
||||
disabled={getMaxCardsNumericValue() <= 0}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
id="maxCards"
|
||||
type="number"
|
||||
className="stepper-input"
|
||||
value={maxCards}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value
|
||||
if (inputValue === '') {
|
||||
setMaxCards('')
|
||||
} else {
|
||||
const numValue = parseInt(inputValue)
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setMaxCards(inputValue)
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleMaxCardsIncrease}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="dictionaries">Словари (необязательно)</label>
|
||||
<div className="dictionaries-hint">
|
||||
Если не выбрано ни одного словаря, будут использоваться все словари
|
||||
</div>
|
||||
{loadingDictionaries ? (
|
||||
<div>Загрузка словарей...</div>
|
||||
) : (
|
||||
<div className="dictionaries-checkbox-list">
|
||||
{dictionaries.map((dict) => (
|
||||
<label key={dict.id} className="dictionary-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDictionaryIds.includes(dict.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
|
||||
} else {
|
||||
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{dict.name} ({dict.wordsCount})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={loading || !name.trim() || getNumericValue() === 0}
|
||||
>
|
||||
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddConfig
|
||||
|
||||
@@ -1,261 +1,31 @@
|
||||
.config-selection {
|
||||
.dictionary-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.add-config-button {
|
||||
background: transparent;
|
||||
border: 2px dashed #3498db;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-config-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
border-color: #2980b9;
|
||||
.dictionary-back-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: #2c3e50;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.add-config-icon {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
line-height: 1;
|
||||
.dictionary-back-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.add-config-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #3498db;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.loading, .error-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.configs-grid {
|
||||
.dictionaries-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
background: #3498db;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-selection .config-card .card-menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem !important;
|
||||
color: white !important;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-selection .config-card .card-menu-button:hover {
|
||||
background: transparent !important;
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.config-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
|
||||
.config-words-count {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-max-cards {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -1rem;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.config-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.config-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.config-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.config-modal-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.config-modal-close:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.config-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.config-modal-edit,
|
||||
.config-modal-delete {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.config-modal-edit {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-modal-edit:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-modal-delete {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-modal-delete:hover {
|
||||
background-color: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dictionaries-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.dictionary-card {
|
||||
@@ -273,7 +43,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-selection .dictionary-card .card-menu-button {
|
||||
.dictionary-list .dictionary-card .dictionary-menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0;
|
||||
@@ -295,7 +65,7 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-selection .dictionary-card .card-menu-button:hover {
|
||||
.dictionary-list .dictionary-card .dictionary-menu-button:hover {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -347,11 +117,99 @@
|
||||
border-color: #1a252f;
|
||||
}
|
||||
|
||||
.add-dictionary-button .add-config-icon {
|
||||
.add-dictionary-icon {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-dictionary-button .add-config-text {
|
||||
.add-dictionary-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.dictionary-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dictionary-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: dictionaryModalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dictionaryModalSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dictionary-modal-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dictionary-modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dictionary-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.dictionary-modal-delete {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dictionary-modal-delete:hover {
|
||||
background-color: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
176
play-life-web/src/components/DictionaryList.jsx
Normal file
176
play-life-web/src/components/DictionaryList.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './DictionaryList.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function DictionaryList({ onNavigate, refreshTrigger = 0 }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedDictionary, setSelectedDictionary] = useState(null)
|
||||
const isInitializedRef = useRef(false)
|
||||
const dictionariesRef = useRef([])
|
||||
|
||||
// Обновляем ref при изменении состояния
|
||||
useEffect(() => {
|
||||
dictionariesRef.current = dictionaries
|
||||
}, [dictionaries])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDictionaries()
|
||||
}, [refreshTrigger])
|
||||
|
||||
const fetchDictionaries = async () => {
|
||||
try {
|
||||
// Показываем загрузку только при первой инициализации или если нет данных для отображения
|
||||
const isFirstLoad = !isInitializedRef.current
|
||||
const hasData = !isFirstLoad && dictionariesRef.current.length > 0
|
||||
if (!hasData) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
const data = await response.json()
|
||||
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
setError('')
|
||||
isInitializedRef.current = true
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setDictionaries([])
|
||||
isInitializedRef.current = true
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDictionarySelect = (dict) => {
|
||||
onNavigate?.('words', { dictionaryId: dict.id })
|
||||
}
|
||||
|
||||
const handleDictionaryMenuClick = (dict, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedDictionary(dict)
|
||||
}
|
||||
|
||||
const handleDictionaryDelete = async () => {
|
||||
if (!selectedDictionary) return
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Delete error:', response.status, errorText)
|
||||
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
|
||||
}
|
||||
|
||||
setSelectedDictionary(null)
|
||||
// Refresh dictionaries list
|
||||
await fetchDictionaries()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message)
|
||||
setSelectedDictionary(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeDictionaryModal = () => {
|
||||
setSelectedDictionary(null)
|
||||
}
|
||||
|
||||
// Показываем загрузку только при первой инициализации и если нет данных для отображения
|
||||
const shouldShowLoading = loading && !isInitializedRef.current && dictionaries.length === 0
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return (
|
||||
<div className="dictionary-list">
|
||||
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="dictionary-list">
|
||||
<LoadingError onRetry={fetchDictionaries} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dictionary-list">
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
className="dictionary-back-button"
|
||||
onClick={() => onNavigate?.('profile')}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="dictionaries-grid">
|
||||
{dictionaries.map((dict) => (
|
||||
<div
|
||||
key={dict.id}
|
||||
className="dictionary-card"
|
||||
onClick={() => handleDictionarySelect(dict)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleDictionaryMenuClick(dict, e)}
|
||||
className="dictionary-menu-button"
|
||||
title="Меню"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className="dictionary-words-count">
|
||||
{dict.wordsCount}
|
||||
</div>
|
||||
<div className="dictionary-name">{dict.name}</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
|
||||
className="add-dictionary-button"
|
||||
>
|
||||
<div className="add-dictionary-icon">+</div>
|
||||
<div className="add-dictionary-text">Добавить</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedDictionary && (
|
||||
<div className="dictionary-modal-overlay" onClick={closeDictionaryModal}>
|
||||
<div className="dictionary-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dictionary-modal-header">
|
||||
<h3>{selectedDictionary.name}</h3>
|
||||
</div>
|
||||
<div className="dictionary-modal-actions">
|
||||
<button className="dictionary-modal-delete" onClick={handleDictionaryDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DictionaryList
|
||||
|
||||
@@ -35,6 +35,36 @@ function Profile({ onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Функционал</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => onNavigate?.('dictionaries')}
|
||||
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||
Словари
|
||||
</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integrations Section */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
||||
|
||||
@@ -413,3 +413,99 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Test configuration styles */
|
||||
.test-config-section {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.test-config-section > label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #3498db;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.test-config-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.test-field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.test-field-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.test-dictionaries-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.test-dictionaries-section > label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.test-dictionaries-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.test-dictionary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.test-dictionary-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.test-dictionary-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #3498db;
|
||||
}
|
||||
|
||||
.test-dictionary-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.test-dictionary-count {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.test-no-dictionaries {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import './TaskForm.css'
|
||||
const API_URL = '/api/tasks'
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
|
||||
function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [progressionBase, setProgressionBase] = useState('')
|
||||
@@ -24,6 +24,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
||||
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
|
||||
// Test-specific state
|
||||
const [isTest, setIsTest] = useState(isTestFromProps)
|
||||
const [wordsCount, setWordsCount] = useState('10')
|
||||
const [maxCards, setMaxCards] = useState('')
|
||||
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
|
||||
const [availableDictionaries, setAvailableDictionaries] = useState([])
|
||||
const debounceTimer = useRef(null)
|
||||
|
||||
// Загрузка проектов для автокомплита
|
||||
@@ -42,6 +48,22 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
loadProjects()
|
||||
}, [])
|
||||
|
||||
// Загрузка словарей для тестов
|
||||
useEffect(() => {
|
||||
const loadDictionaries = async () => {
|
||||
try {
|
||||
const response = await authFetch('/api/test-configs-and-dictionaries')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAvailableDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading dictionaries:', err)
|
||||
}
|
||||
}
|
||||
loadDictionaries()
|
||||
}, [])
|
||||
|
||||
// Функция сброса формы
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
@@ -54,6 +76,11 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
setSubtasks([])
|
||||
setError('')
|
||||
setLoadingTask(false)
|
||||
// Reset test-specific fields
|
||||
setIsTest(isTestFromProps)
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setSelectedDictionaryIDs([])
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = null
|
||||
@@ -316,6 +343,28 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
setCurrentWishlistId(null)
|
||||
setWishlistInfo(null)
|
||||
}
|
||||
|
||||
// Загружаем информацию о тесте, если есть config_id
|
||||
if (data.task.config_id) {
|
||||
setIsTest(true)
|
||||
// Данные теста приходят прямо в ответе getTaskDetail
|
||||
if (data.words_count) {
|
||||
setWordsCount(String(data.words_count))
|
||||
}
|
||||
if (data.max_cards) {
|
||||
setMaxCards(String(data.max_cards))
|
||||
}
|
||||
if (data.dictionary_ids && Array.isArray(data.dictionary_ids)) {
|
||||
setSelectedDictionaryIDs(data.dictionary_ids)
|
||||
}
|
||||
// Тесты не могут иметь прогрессию
|
||||
setProgressionBase('')
|
||||
} else {
|
||||
setIsTest(false)
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setSelectedDictionaryIDs([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -551,11 +600,26 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для тестов
|
||||
if (isTest) {
|
||||
const wordsCountNum = parseInt(wordsCount, 10)
|
||||
if (isNaN(wordsCountNum) || wordsCountNum < 1) {
|
||||
setError('Количество слов должно быть минимум 1')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (selectedDictionaryIDs.length === 0) {
|
||||
setError('Выберите хотя бы один словарь')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
reward_message: rewardMessage.trim() || null,
|
||||
// Если задача привязана к желанию, не отправляем progression_base
|
||||
progression_base: isLinkedToWishlist ? null : (progressionBase ? parseFloat(progressionBase) : null),
|
||||
// Тесты и задачи с желанием не могут иметь прогрессию
|
||||
progression_base: (isLinkedToWishlist || isTest) ? null : (progressionBase ? parseFloat(progressionBase) : null),
|
||||
repetition_period: repetitionPeriod,
|
||||
repetition_date: repetitionDate,
|
||||
// При создании: отправляем currentWishlistId если указан (уже число)
|
||||
@@ -580,7 +644,12 @@ function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||
value: parseFloat(r.value) || 0,
|
||||
use_progression: !!(progressionBase && r.use_progression)
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
// Test-specific fields
|
||||
is_test: isTest,
|
||||
words_count: isTest ? parseInt(wordsCount, 10) : undefined,
|
||||
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
|
||||
dictionary_ids: isTest ? selectedDictionaryIDs : undefined
|
||||
}
|
||||
|
||||
const url = taskId ? `${API_URL}/${taskId}` : API_URL
|
||||
@@ -715,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>
|
||||
|
||||
@@ -512,3 +512,101 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Badge icons for test and wishlist tasks */
|
||||
.task-test-icon {
|
||||
color: #3498db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-wishlist-icon {
|
||||
color: #e74c3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Add task/test modal */
|
||||
.task-add-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.task-add-modal {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 320px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.task-add-modal-header {
|
||||
padding: 1.25rem 1.5rem 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-add-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.task-add-modal-buttons {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-add-modal-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-add-modal-button-task {
|
||||
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-add-modal-button-task:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.task-add-modal-button-test {
|
||||
background: linear-gradient(to right, #3498db, #2980b9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-add-modal-button-test:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
const [postponeDate, setPostponeDate] = useState('')
|
||||
const [isPostponing, setIsPostponing] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const dateInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,7 +37,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
const handleCheckmarkClick = async (task, e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Всегда открываем диалог подтверждения
|
||||
// Для задач-тестов запускаем тест вместо открытия модального окна
|
||||
const isTest = task.config_id != null
|
||||
if (isTest) {
|
||||
if (task.config_id) {
|
||||
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Для обычных задач открываем диалог подтверждения
|
||||
setSelectedTaskForDetail(task.id)
|
||||
}
|
||||
|
||||
@@ -45,9 +55,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
}
|
||||
|
||||
const handleAddClick = () => {
|
||||
onNavigate?.('task-form', { taskId: undefined })
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
const handleAddTask = () => {
|
||||
setShowAddModal(false)
|
||||
onNavigate?.('task-form', { taskId: undefined, isTest: false })
|
||||
}
|
||||
|
||||
const handleAddTest = () => {
|
||||
setShowAddModal(false)
|
||||
onNavigate?.('task-form', { taskId: undefined, isTest: true })
|
||||
}
|
||||
|
||||
|
||||
// Функция для вычисления следующей даты по repetition_date
|
||||
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
||||
if (!repetitionDateStr) return null
|
||||
@@ -490,6 +511,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
const hasProgression = task.has_progression || task.progression_base != null
|
||||
const hasSubtasks = task.subtasks_count > 0
|
||||
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
||||
const isTest = task.config_id != null
|
||||
const isWishlist = task.wishlist_id != null
|
||||
|
||||
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
|
||||
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
|
||||
@@ -513,7 +536,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
<div
|
||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||
onClick={(e) => handleCheckmarkClick(task, e)}
|
||||
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
|
||||
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||
@@ -528,6 +551,43 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
||||
)}
|
||||
<span className="task-badge-bar">
|
||||
{isWishlist && (
|
||||
<svg
|
||||
className="task-wishlist-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
title="Связано с желанием"
|
||||
>
|
||||
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||
<rect x="2" y="7" width="20" height="5"></rect>
|
||||
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{isTest && (
|
||||
<svg
|
||||
className="task-test-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
title="Тест"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{hasProgression && (
|
||||
<svg
|
||||
className="task-progression-icon"
|
||||
@@ -741,6 +801,41 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модальное окно выбора типа задачи */}
|
||||
{showAddModal && (
|
||||
<div className="task-add-modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="task-add-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="task-add-modal-header">
|
||||
<h3>Что добавить?</h3>
|
||||
</div>
|
||||
<div className="task-add-modal-buttons">
|
||||
<button
|
||||
className="task-add-modal-button task-add-modal-button-task"
|
||||
onClick={handleAddTask}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
Задача
|
||||
</button>
|
||||
<button
|
||||
className="task-add-modal-button task-add-modal-button-test"
|
||||
onClick={handleAddTest}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
<path d="M8 7h6"></path>
|
||||
<path d="M8 11h4"></path>
|
||||
</svg>
|
||||
Тест
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно для переноса задачи */}
|
||||
{selectedTaskForPostpone && (() => {
|
||||
const todayStr = formatDateToLocal(new Date())
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './TestConfigSelection.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [configs, setConfigs] = useState([])
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedConfig, setSelectedConfig] = useState(null)
|
||||
const [selectedDictionary, setSelectedDictionary] = useState(null)
|
||||
const [longPressTimer, setLongPressTimer] = useState(null)
|
||||
const isInitializedRef = useRef(false)
|
||||
const configsRef = useRef([])
|
||||
const dictionariesRef = useRef([])
|
||||
|
||||
// Обновляем ref при изменении состояния
|
||||
useEffect(() => {
|
||||
configsRef.current = configs
|
||||
}, [configs])
|
||||
|
||||
useEffect(() => {
|
||||
dictionariesRef.current = dictionaries
|
||||
}, [dictionaries])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTestConfigsAndDictionaries()
|
||||
}, [refreshTrigger])
|
||||
|
||||
|
||||
const fetchTestConfigsAndDictionaries = async () => {
|
||||
try {
|
||||
// Показываем загрузку только при первой инициализации или если нет данных для отображения
|
||||
const isFirstLoad = !isInitializedRef.current
|
||||
const hasData = !isFirstLoad && (configsRef.current.length > 0 || dictionariesRef.current.length > 0)
|
||||
if (!hasData) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
||||
}
|
||||
const data = await response.json()
|
||||
setConfigs(Array.isArray(data.configs) ? data.configs : [])
|
||||
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
setError('')
|
||||
isInitializedRef.current = true
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setConfigs([])
|
||||
setDictionaries([])
|
||||
isInitializedRef.current = true
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfigSelect = (config) => {
|
||||
onNavigate?.('test', {
|
||||
wordCount: config.words_count,
|
||||
configId: config.id,
|
||||
maxCards: config.max_cards || null
|
||||
})
|
||||
}
|
||||
|
||||
const handleDictionarySelect = (dict) => {
|
||||
// For now, navigate to words list
|
||||
// In the future, we might want to filter by dictionary_id
|
||||
onNavigate?.('words', { dictionaryId: dict.id })
|
||||
}
|
||||
|
||||
const handleConfigMenuClick = (config, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedConfig(config)
|
||||
}
|
||||
|
||||
const handleDictionaryMenuClick = (dict, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedDictionary(dict)
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (selectedConfig) {
|
||||
onNavigate?.('add-config', { config: selectedConfig })
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDictionaryDelete = async () => {
|
||||
if (!selectedDictionary) return
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Delete error:', response.status, errorText)
|
||||
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
|
||||
}
|
||||
|
||||
setSelectedDictionary(null)
|
||||
// Refresh dictionaries list
|
||||
await fetchTestConfigsAndDictionaries()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message)
|
||||
setSelectedDictionary(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedConfig) return
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Delete error:', response.status, errorText)
|
||||
throw new Error(`Ошибка при удалении конфигурации: ${response.status}`)
|
||||
}
|
||||
|
||||
setSelectedConfig(null)
|
||||
// Refresh configs and dictionaries list
|
||||
await fetchTestConfigsAndDictionaries()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message)
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
|
||||
// Показываем загрузку только при первой инициализации и если нет данных для отображения
|
||||
const shouldShowLoading = loading && !isInitializedRef.current && configs.length === 0 && dictionaries.length === 0
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return (
|
||||
<div className="config-selection">
|
||||
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="config-selection">
|
||||
<LoadingError onRetry={fetchTestConfigsAndDictionaries} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-selection">
|
||||
{/* Секция тестов */}
|
||||
<div className="section-divider">
|
||||
<h2 className="section-title">Тесты</h2>
|
||||
</div>
|
||||
<div className="configs-grid">
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="config-card"
|
||||
onClick={() => handleConfigSelect(config)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleConfigMenuClick(config, e)}
|
||||
className="card-menu-button"
|
||||
title="Меню"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className="config-words-count">
|
||||
{config.words_count}
|
||||
</div>
|
||||
{config.max_cards && (
|
||||
<div className="config-max-cards">
|
||||
{config.max_cards}
|
||||
</div>
|
||||
)}
|
||||
<div className="config-name">{config.name}</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => onNavigate?.('add-config')} className="add-config-button">
|
||||
<div className="add-config-icon">+</div>
|
||||
<div className="add-config-text">Добавить</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Секция словарей */}
|
||||
<div className="dictionaries-section">
|
||||
<div className="section-divider">
|
||||
<h2 className="section-title">Словари</h2>
|
||||
</div>
|
||||
<div className="configs-grid">
|
||||
{dictionaries.map((dict) => (
|
||||
<div
|
||||
key={dict.id}
|
||||
className="dictionary-card"
|
||||
onClick={() => handleDictionarySelect(dict)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleDictionaryMenuClick(dict, e)}
|
||||
className="card-menu-button"
|
||||
title="Меню"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className="dictionary-words-count">
|
||||
{dict.wordsCount}
|
||||
</div>
|
||||
<div className="dictionary-name">{dict.name}</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
|
||||
className="add-dictionary-button"
|
||||
>
|
||||
<div className="add-config-icon">+</div>
|
||||
<div className="add-config-text">Добавить</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{selectedConfig && (
|
||||
<div className="config-modal-overlay" onClick={closeModal}>
|
||||
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="config-modal-header">
|
||||
<h3>{selectedConfig.name}</h3>
|
||||
</div>
|
||||
<div className="config-modal-actions">
|
||||
<button className="config-modal-edit" onClick={handleEdit}>
|
||||
Редактировать
|
||||
</button>
|
||||
<button className="config-modal-delete" onClick={handleDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDictionary && (
|
||||
<div className="config-modal-overlay" onClick={() => setSelectedDictionary(null)}>
|
||||
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="config-modal-header">
|
||||
<h3>{selectedDictionary.name}</h3>
|
||||
</div>
|
||||
<div className="config-modal-actions">
|
||||
<button className="config-modal-delete" onClick={handleDictionaryDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestConfigSelection
|
||||
|
||||
@@ -8,11 +8,12 @@ const API_URL = '/api'
|
||||
|
||||
const DEFAULT_TEST_WORD_COUNT = 10
|
||||
|
||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
|
||||
const { authFetch } = useAuth()
|
||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||
const configId = initialConfigId || null
|
||||
const maxCards = initialMaxCards || null
|
||||
const taskId = initialTaskId || null
|
||||
|
||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||
@@ -366,6 +367,25 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
console.log('Test progress saved successfully:', responseData)
|
||||
|
||||
// Если есть taskId, выполняем задачу
|
||||
if (taskId) {
|
||||
try {
|
||||
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
if (completeResponse.ok) {
|
||||
console.log('Task completed successfully')
|
||||
} else {
|
||||
console.error('Failed to complete task:', await completeResponse.text())
|
||||
}
|
||||
} catch (taskErr) {
|
||||
console.error('Failed to complete task:', taskErr)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save progress:', err)
|
||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||
@@ -537,7 +557,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate?.('test-config')
|
||||
onNavigate?.('tasks')
|
||||
}
|
||||
|
||||
const handleStartTest = () => {
|
||||
@@ -547,7 +567,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onNavigate?.('test-config')
|
||||
onNavigate?.('tasks')
|
||||
}
|
||||
|
||||
const getRandomSide = (word) => {
|
||||
|
||||
@@ -189,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
return (
|
||||
<div className="word-list">
|
||||
<button
|
||||
onClick={() => onNavigate?.('test-config')}
|
||||
onClick={() => onNavigate?.('dictionaries')}
|
||||
className="close-x-button"
|
||||
title="Закрыть"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user