Унификация отображения ошибок: LoadingError для загрузки, Toast для действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
This commit is contained in:
@@ -99,6 +99,7 @@ function AppContent() {
|
||||
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
||||
const [prioritiesError, setPrioritiesError] = useState(null)
|
||||
const [tasksError, setTasksError] = useState(null)
|
||||
|
||||
// Состояние для кнопки Refresh (если она есть)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
@@ -344,6 +345,7 @@ function AppContent() {
|
||||
} else {
|
||||
setTasksLoading(true)
|
||||
}
|
||||
setTasksError(null)
|
||||
const response = await authFetch('/api/tasks')
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
@@ -352,6 +354,7 @@ function AppContent() {
|
||||
setTasksData(jsonData)
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки списка задач:', err)
|
||||
setTasksError(err.message || 'Ошибка загрузки данных')
|
||||
} finally {
|
||||
if (isBackground) {
|
||||
setTasksBackgroundLoading(false)
|
||||
@@ -834,6 +837,8 @@ function AppContent() {
|
||||
data={tasksData}
|
||||
loading={tasksLoading}
|
||||
backgroundLoading={tasksBackgroundLoading}
|
||||
error={tasksError}
|
||||
onRetry={() => fetchTasksData(false)}
|
||||
onRefresh={(isBackground = false) => fetchTasksData(isBackground)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProjectProgressBar from './ProjectProgressBar'
|
||||
import LoadingError from './LoadingError'
|
||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
|
||||
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
||||
@@ -18,20 +19,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
||||
}
|
||||
|
||||
if (error && (!data || projectsData.length === 0)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
return <LoadingError onRetry={onRetry} />
|
||||
}
|
||||
|
||||
// Процент выполнения берем только из данных API
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'chart.js'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import WeekProgressChart from './WeekProgressChart'
|
||||
import LoadingError from './LoadingError'
|
||||
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||
import './Integrations.css'
|
||||
|
||||
@@ -129,20 +130,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
||||
}
|
||||
|
||||
if (error && !chartData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
return <LoadingError onRetry={onRetry} />
|
||||
}
|
||||
|
||||
const chartOptions = {
|
||||
|
||||
54
play-life-web/src/components/LoadingError.css
Normal file
54
play-life-web/src/components/LoadingError.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.loading-error-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 80px; /* Отступ для нижнего бара */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Учитываем safe-area для мобильных устройств */
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.loading-error-container {
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.loading-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-error-text {
|
||||
color: #374151;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-error-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(to right, #4f46e5, #9333ea);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.loading-error-button:hover {
|
||||
background: linear-gradient(to right, #4338ca, #7e22ce);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.loading-error-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
23
play-life-web/src/components/LoadingError.jsx
Normal file
23
play-life-web/src/components/LoadingError.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import './LoadingError.css'
|
||||
|
||||
function LoadingError({ onRetry }) {
|
||||
return (
|
||||
<div className="loading-error-container">
|
||||
<div className="loading-error-content">
|
||||
<div className="loading-error-text">Ошибка, повторите позже</div>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="loading-error-button"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingError
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import Toast from './Toast'
|
||||
import './Integrations.css'
|
||||
|
||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||
@@ -29,20 +31,20 @@ const PROJECT_MOVE_API_URL = '/project/move'
|
||||
const PROJECT_CREATE_API_URL = '/project/create'
|
||||
|
||||
// Компонент экрана добавления проекта
|
||||
function AddProjectScreen({ onClose, onSuccess }) {
|
||||
function AddProjectScreen({ onClose, onSuccess, onError }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [validationError, setValidationError] = useState(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!projectName.trim()) {
|
||||
setError('Введите название проекта')
|
||||
setValidationError('Введите название проекта')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
setValidationError(null)
|
||||
|
||||
try {
|
||||
const response = await authFetch(PROJECT_CREATE_API_URL, {
|
||||
@@ -61,7 +63,9 @@ function AddProjectScreen({ onClose, onSuccess }) {
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания проекта:', err)
|
||||
setError(err.message || 'Ошибка при создании проекта')
|
||||
if (onError) {
|
||||
onError(err.message || 'Ошибка при создании проекта')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -126,10 +130,10 @@ function AddProjectScreen({ onClose, onSuccess }) {
|
||||
}
|
||||
|
||||
// Компонент экрана переноса проекта
|
||||
function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
||||
function MoveProjectScreen({ project, allProjects, onClose, onSuccess, onError }) {
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [validationError, setValidationError] = useState(null)
|
||||
|
||||
const handleProjectClick = (projectName) => {
|
||||
setNewProjectName(projectName)
|
||||
@@ -137,12 +141,12 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newProjectName.trim()) {
|
||||
setError('Введите название проекта')
|
||||
setValidationError('Введите название проекта')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
setValidationError(null)
|
||||
|
||||
try {
|
||||
const projectId = project.id ?? project.name
|
||||
@@ -163,7 +167,9 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
console.error('Ошибка переноса проекта:', err)
|
||||
setError(err.message || 'Ошибка при переносе проекта')
|
||||
if (onError) {
|
||||
onError(err.message || 'Ошибка при переносе проекта')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -210,8 +216,8 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
||||
placeholder="Введите новое название проекта"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-2 text-sm text-red-600">{error}</div>
|
||||
{validationError && (
|
||||
<div className="mt-2 text-sm text-red-600">{validationError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -375,6 +381,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||
const [projectsError, setProjectsError] = useState(null)
|
||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
// Уведомляем родительский компонент об изменении состояния загрузки
|
||||
useEffect(() => {
|
||||
@@ -848,7 +855,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
fetchProjects()
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления проекта:', error)
|
||||
setProjectsError(error.message || 'Ошибка удаления проекта')
|
||||
setToastMessage({ text: error.message || 'Ошибка удаления проекта', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,18 +883,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
</button>
|
||||
)}
|
||||
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm flex-shrink-0">
|
||||
<div className="font-semibold">Не удалось загрузить проекты</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-red-600">{projectsError}</span>
|
||||
<button
|
||||
onClick={() => fetchProjects()}
|
||||
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingError onRetry={fetchProjects} />
|
||||
)}
|
||||
|
||||
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
|
||||
@@ -1013,6 +1009,9 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
setSelectedProject(null)
|
||||
fetchProjects()
|
||||
}}
|
||||
onError={(errorMessage) => {
|
||||
setToastMessage({ text: errorMessage, type: 'error' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1024,6 +1023,17 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
setShowAddScreen(false)
|
||||
fetchProjects()
|
||||
}}
|
||||
onError={(errorMessage) => {
|
||||
setToastMessage({ text: errorMessage, type: 'error' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import Toast from './Toast'
|
||||
import './TaskDetail.css'
|
||||
|
||||
const API_URL = '/api/tasks'
|
||||
@@ -379,6 +381,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||
const [selectedSubtasks, setSelectedSubtasks] = useState(new Set())
|
||||
const [progressionValue, setProgressionValue] = useState('')
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -479,7 +482,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error completing task:', err)
|
||||
alert(err.message || 'Ошибка при выполнении задачи')
|
||||
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
}
|
||||
@@ -547,8 +550,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||
<div className="loading">Загрузка...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
{error && !loading && (
|
||||
<LoadingError onRetry={fetchTaskDetail} />
|
||||
)}
|
||||
|
||||
{!loading && !error && taskDetail && (
|
||||
@@ -646,6 +649,13 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import Toast from './Toast'
|
||||
import './TaskForm.css'
|
||||
|
||||
const API_URL = '/api/tasks'
|
||||
@@ -17,7 +18,8 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
const [subtasks, setSubtasks] = useState([])
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [error, setError] = useState('') // Только для валидации
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
const [loadingTask, setLoadingTask] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const debounceTimer = useRef(null)
|
||||
@@ -534,7 +536,7 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
// Возвращаемся к списку задач
|
||||
onNavigate?.('tasks')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
|
||||
console.error('Error saving task:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -567,7 +569,7 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
onNavigate?.('tasks')
|
||||
} catch (err) {
|
||||
console.error('Error deleting task:', err)
|
||||
setError('Ошибка при удалении задачи')
|
||||
setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
@@ -864,7 +866,10 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{/* Показываем ошибку валидации только если это ошибка валидации, не ошибка действия */}
|
||||
{error && (error.includes('обязательно') || error.includes('должны быть заполнены') || error.includes('нельзя одновременно')) && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={loading || isDeleting} className="submit-button">
|
||||
@@ -893,6 +898,13 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import TaskDetail from './TaskDetail'
|
||||
import LoadingError from './LoadingError'
|
||||
import Toast from './Toast'
|
||||
import './TaskList.css'
|
||||
|
||||
const API_URL = '/api/tasks'
|
||||
|
||||
function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry, onRefresh }) {
|
||||
const { authFetch } = useAuth()
|
||||
// Инициализируем tasks из data, если data есть, иначе пустой массив
|
||||
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
|
||||
@@ -351,7 +352,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
setPostponeDate('')
|
||||
} catch (err) {
|
||||
console.error('Error postponing task:', err)
|
||||
alert(err.message || 'Ошибка при переносе задачи')
|
||||
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
|
||||
} finally {
|
||||
setIsPostponing(false)
|
||||
}
|
||||
@@ -632,6 +633,15 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0
|
||||
const hasData = hasDataInProps || hasDataInState
|
||||
|
||||
// Показываем ошибку загрузки, если есть ошибка и нет данных
|
||||
if (error && !hasData && !loading) {
|
||||
return (
|
||||
<div className="task-list">
|
||||
<LoadingError onRetry={onRetry} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем загрузку только если:
|
||||
// 1. Идет загрузка (loading = true)
|
||||
// 2. Это не фоновая загрузка (backgroundLoading = false)
|
||||
@@ -657,6 +667,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type || 'success'}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -721,7 +732,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
taskId={selectedTaskForDetail}
|
||||
onClose={handleCloseDetail}
|
||||
onRefresh={onRefresh}
|
||||
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
|
||||
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './Integrations.css'
|
||||
|
||||
function TelegramIntegration({ onNavigate }) {
|
||||
@@ -50,6 +51,17 @@ function TelegramIntegration({ onNavigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !integration) {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||
✕
|
||||
</button>
|
||||
<LoadingError onRetry={fetchIntegration} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||
@@ -58,12 +70,6 @@ function TelegramIntegration({ onNavigate }) {
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './TestConfigSelection.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
@@ -167,7 +168,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="config-selection">
|
||||
<div className="error-message">{error}</div>
|
||||
<LoadingError onRetry={fetchTestConfigsAndDictionaries} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './TestWords.css'
|
||||
import './Integrations.css'
|
||||
|
||||
@@ -625,7 +626,62 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="test-error">{error}</div>
|
||||
<LoadingError onRetry={() => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
// Перезагружаем слова
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
if (configId === null) {
|
||||
throw new Error('config_id обязателен для запуска теста')
|
||||
}
|
||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||
const response = await authFetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('Недостаточно слов для теста')
|
||||
}
|
||||
|
||||
const stats = {}
|
||||
data.forEach(word => {
|
||||
stats[word.id] = {
|
||||
success: word.success || 0,
|
||||
failure: word.failure || 0,
|
||||
lastSuccessAt: word.last_success_at || null,
|
||||
lastFailureAt: word.last_failure_at || null
|
||||
}
|
||||
})
|
||||
|
||||
setWords(data)
|
||||
|
||||
const wordsCount = data.length
|
||||
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
||||
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||
|
||||
let wordPool = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
wordPool.push(...data)
|
||||
}
|
||||
|
||||
wordPool = redistributeWordsEvenly(wordPool, data)
|
||||
|
||||
setTestWords(wordPool)
|
||||
setWordStats(stats)
|
||||
wordStatsRef.current = stats
|
||||
setShowPreview(true)
|
||||
setCardsShown(0)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadWords()
|
||||
}} />
|
||||
)}
|
||||
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
|
||||
const word = currentWord
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
@@ -32,3 +41,7 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-error .toast-message {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import './Toast.css'
|
||||
|
||||
function Toast({ message, onClose, duration = 3000 }) {
|
||||
function Toast({ message, onClose, duration = 3000, type = 'success' }) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,7 +18,7 @@ function Toast({ message, onClose, duration = 3000 }) {
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className={`toast ${isVisible ? 'toast-visible' : ''}`}>
|
||||
<div className={`toast toast-${type} ${isVisible ? 'toast-visible' : ''}`}>
|
||||
<div className="toast-content">
|
||||
<span className="toast-message">{message}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import Toast from './Toast'
|
||||
import './Integrations.css'
|
||||
|
||||
function TodoistIntegration({ onNavigate }) {
|
||||
@@ -9,6 +11,8 @@ function TodoistIntegration({ onNavigate }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
const [isLoadingError, setIsLoadingError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
@@ -23,7 +27,7 @@ function TodoistIntegration({ onNavigate }) {
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
} else if (status === 'error') {
|
||||
const errorMsg = params.get('message') || 'Произошла ошибка'
|
||||
setError(errorMsg)
|
||||
setToastMessage({ text: errorMsg, type: 'error' })
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +49,7 @@ function TodoistIntegration({ onNavigate }) {
|
||||
} catch (error) {
|
||||
console.error('Error checking status:', error)
|
||||
setError(error.message || 'Не удалось проверить статус')
|
||||
setIsLoadingError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -69,7 +74,7 @@ function TodoistIntegration({ onNavigate }) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting Todoist:', error)
|
||||
setError(error.message || 'Не удалось подключить Todoist')
|
||||
setToastMessage({ text: error.message || 'Не удалось подключить Todoist', type: 'error' })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -91,15 +96,26 @@ function TodoistIntegration({ onNavigate }) {
|
||||
}
|
||||
setConnected(false)
|
||||
setTodoistEmail('')
|
||||
setMessage('Todoist отключен')
|
||||
setToastMessage({ text: 'Todoist отключен', type: 'success' })
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting:', error)
|
||||
setError(error.message || 'Не удалось отключить Todoist')
|
||||
setToastMessage({ text: error.message || 'Не удалось отключить Todoist', type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingError && !loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||
✕
|
||||
</button>
|
||||
<LoadingError onRetry={checkStatus} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||
@@ -108,18 +124,6 @@ function TodoistIntegration({ onNavigate }) {
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Todoist интеграция</h1>
|
||||
|
||||
{message && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6 text-green-800">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="fixed inset-0 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -192,6 +196,13 @@ function TodoistIntegration({ onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './WordList.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
@@ -176,7 +177,11 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
if (error) {
|
||||
return (
|
||||
<div className="word-list">
|
||||
<div className="error-message">{error}</div>
|
||||
<LoadingError onRetry={() => {
|
||||
if (hasValidDictionary(currentDictionaryId)) {
|
||||
fetchWordsForDictionary(currentDictionaryId)
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user