Рефакторинг тестов: интеграция с задачами
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,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="Закрыть"
>