Первоначальный коммит

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
poignatov-home
2026-02-08 17:01:36 +03:00
commit bad198ce29
217 changed files with 57075 additions and 0 deletions

1574
play-life-web/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
.add-words {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.add-words {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-words h2 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
font-size: 2rem;
}
.description {
margin-bottom: 1.5rem;
color: #666;
font-size: 0.95rem;
}
.markdown-input {
width: 100%;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 1rem;
transition: border-color 0.2s;
}
.markdown-input:focus {
outline: none;
border-color: #3498db;
}
.tabs-container {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e0e0e0;
}
.tab-button {
background: none;
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
color: #666;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.tab-button:hover {
color: #3498db;
}
.tab-button.active {
color: #3498db;
border-bottom-color: #3498db;
font-weight: 600;
}
.tab-content {
margin-bottom: 1rem;
}
.word-pairs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.word-pair-item {
display: flex;
flex-direction: row;
gap: 0.75rem;
width: 100%;
box-sizing: border-box;
}
.word-input,
.translation-input {
flex: 1;
min-width: 0;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
box-sizing: border-box;
}
.word-input:focus,
.translation-input:focus {
outline: none;
border-color: #3498db;
}
.remove-pair-button {
background: transparent;
border: none;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
width: 2rem;
min-width: 2rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-sizing: border-box;
flex-shrink: 0;
align-self: stretch;
}
.remove-pair-button svg {
display: block;
margin: 0;
vertical-align: middle;
}
.remove-pair-button:hover {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.remove-pair-button:active {
background-color: rgba(239, 68, 68, 0.2);
transform: scale(0.95);
}
.add-pair-button {
width: 100%;
padding: 0.75rem;
background-color: #f8f9fa;
border: 2px dashed #ddd;
border-radius: 4px;
font-size: 1.5rem;
line-height: 1;
color: #666;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: calc(0.75rem * 2 + 1rem + 4px);
}
.add-pair-button:hover {
background-color: #e9ecef;
border-color: #3498db;
color: #3498db;
}
.submit-button {
width: 100%;
background-color: #3498db;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
margin-bottom: 1rem;
}
.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;
}
.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;
}

View File

@@ -0,0 +1,325 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddWords.css'
const API_URL = '/api'
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
const { authFetch } = useAuth()
const [activeTab, setActiveTab] = useState('words')
const [markdownText, setMarkdownText] = useState('')
const [wordPairs, setWordPairs] = useState([{ word: '', translation: '' }])
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [currentDictionaryName, setCurrentDictionaryName] = useState(dictionaryName || '')
const [dictionaryLoading, setDictionaryLoading] = useState(false)
// Fetch dictionary name if not provided and dictionaryId exists
useEffect(() => {
if (dictionaryName) {
setCurrentDictionaryName(dictionaryName)
} else if (dictionaryId) {
fetchDictionaryName(dictionaryId)
}
}, [dictionaryId, dictionaryName])
const fetchDictionaryName = async (dictId) => {
if (!dictId) return
setDictionaryLoading(true)
try {
const response = await authFetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const dictionaries = await response.json()
const dict = dictionaries.find(d => d.id === dictId)
if (dict) {
setCurrentDictionaryName(dict.name)
}
} catch (err) {
console.error('Error fetching dictionary name:', err)
} finally {
setDictionaryLoading(false)
}
}
// Hide add button if dictionary name is not set
const canAddWords = currentDictionaryName && currentDictionaryName.trim() !== ''
const handleAddPair = () => {
setWordPairs([...wordPairs, { word: '', translation: '' }])
}
const handleRemovePair = (index) => {
const newPairs = wordPairs.filter((_, i) => i !== index)
setWordPairs(newPairs)
}
const handlePairChange = (index, field, value) => {
const newPairs = [...wordPairs]
newPairs[index][field] = value
setWordPairs(newPairs)
}
const parseMarkdownTable = (text) => {
const lines = text.split('\n')
const words = []
let foundTable = false
let headerFound = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// Skip empty lines
if (!line) continue
// Look for table start (markdown table with |)
if (line.includes('|') && line.includes('Слово')) {
foundTable = true
headerFound = true
continue
}
// Skip separator line (|---|---|)
if (foundTable && line.match(/^\|[\s\-|:]+\|$/)) {
continue
}
// Parse table rows
if (foundTable && headerFound && line.includes('|')) {
const cells = line
.split('|')
.map(cell => (cell || '').trim())
.filter(cell => cell && cell.length > 0)
if (cells.length >= 2) {
// Remove markdown formatting (**bold**, etc.)
const word = cells[0].replace(/\*\*/g, '').trim()
const translation = cells[1].replace(/\*\*/g, '').trim()
if (word && translation) {
words.push({
name: word,
translation: translation,
description: ''
})
}
}
}
}
return words
}
const handleSubmit = async (e) => {
e.preventDefault()
setMessage('')
setLoading(true)
try {
let words = []
if (activeTab === 'table') {
words = parseMarkdownTable(markdownText)
if (words.length === 0) {
setMessage('Не удалось найти слова в таблице. Убедитесь, что таблица содержит колонки "Слово" и "Перевод".')
setLoading(false)
return
}
} else if (activeTab === 'words') {
// Filter out empty pairs and convert to words format
words = wordPairs
.filter(pair => pair.word.trim() && pair.translation.trim())
.map(pair => ({
name: pair.word.trim(),
translation: pair.translation.trim(),
description: ''
}))
if (words.length === 0) {
setMessage('Добавьте хотя бы одно слово с переводом.')
setLoading(false)
return
}
}
// Add dictionary_id to each word if dictionaryId is provided
const wordsWithDictionary = words.map(word => ({
...word,
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
}))
const response = await authFetch(`${API_URL}/words`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ words: wordsWithDictionary }),
})
if (!response.ok) {
throw new Error('Ошибка при добавлении слов')
}
const data = await response.json()
const addedCount = data?.added || 0
setMessage(`Успешно добавлено ${addedCount} слов(а)!`)
// Reset form based on active tab
if (activeTab === 'table') {
setMarkdownText('')
} else if (activeTab === 'words') {
setWordPairs([{ word: '', translation: '' }])
}
} catch (error) {
setMessage(`Ошибка: ${error.message}`)
} finally {
setLoading(false)
}
}
// Check if form can be submitted
const canSubmit = () => {
if (!canAddWords) return false
if (activeTab === 'table') {
return markdownText.trim().length > 0
} else if (activeTab === 'words') {
return wordPairs.some(pair => pair.word.trim() && pair.translation.trim())
}
return false
}
const handleClose = () => {
window.history.back()
}
// Show loading state while fetching dictionary name
if (dictionaryLoading) {
return (
<div className="add-words">
<div className="fixed inset-0 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>
)
}
return (
<div className="add-words">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Добавить слова</h2>
<div className="tabs-container">
<button
className={`tab-button ${activeTab === 'words' ? 'active' : ''}`}
onClick={() => setActiveTab('words')}
>
Слова
</button>
<button
className={`tab-button ${activeTab === 'table' ? 'active' : ''}`}
onClick={() => setActiveTab('table')}
>
Таблица
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="tab-content">
{activeTab === 'table' && (
<>
<p className="description">
Вставьте текст в формате Markdown с таблицей, содержащей колонки "Слово" и "Перевод"
</p>
<textarea
className="markdown-input"
value={markdownText}
onChange={(e) => setMarkdownText(e.target.value)}
placeholder={`Вот таблица, содержащая только слова и их перевод:
| Слово (Word) | Перевод |
| --- | --- |
| **Adventure** | Приключение |
| **Challenge** | Вызов, сложная задача |
| **Decision** | Решение |`}
rows={15}
/>
</>
)}
{activeTab === 'words' && (
<div className="word-pairs-list">
{wordPairs.map((pair, index) => (
<div key={index} className="word-pair-item">
<input
type="text"
className="word-input"
placeholder="Слово"
value={pair.word}
onChange={(e) => handlePairChange(index, 'word', e.target.value)}
/>
<input
type="text"
className="translation-input"
placeholder="Перевод"
value={pair.translation}
onChange={(e) => handlePairChange(index, 'translation', e.target.value)}
/>
<button
type="button"
className="remove-pair-button"
onClick={() => handleRemovePair(index)}
title="Удалить"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
))}
<button
type="button"
className="add-pair-button"
onClick={handleAddPair}
>
+
</button>
</div>
)}
</div>
{canAddWords && (
<button
type="submit"
className="submit-button"
disabled={loading || !canSubmit()}
>
{loading ? 'Добавление...' : 'Добавить слова'}
</button>
)}
{!canAddWords && (
<div className="message error">
Сначала установите название словаря на экране списка слов
</div>
)}
</form>
{message && (
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
{message}
</div>
)}
</div>
)
}
export default AddWords

View File

@@ -0,0 +1,132 @@
.board-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.board-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.form-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.form-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
/* Toggle switch */
.toggle-field {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-field input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 48px;
height: 26px;
background: #d1d5db;
border-radius: 13px;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-field input:checked + .toggle-slider {
background: #6366f1;
}
.toggle-field input:checked + .toggle-slider::after {
transform: translateX(22px);
}
.toggle-label {
font-size: 0.95rem;
color: #374151;
}
/* Invite link section */
.invite-link-section {
margin-top: 1rem;
}
.invite-url-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.invite-url-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
background: #f9fafb;
color: #374151;
font-family: monospace;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1.1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.copy-btn:hover {
background: #4f46e5;
}
.invite-hint {
margin-top: 8px;
font-size: 0.85rem;
color: #6b7280;
}

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers'
import Toast from './Toast'
import SubmitButton from './SubmitButton'
import DeleteButton from './DeleteButton'
import './Buttons.css'
import './BoardForm.css'
function BoardForm({ boardId, onNavigate, onSaved }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false)
const [inviteURL, setInviteURL] = useState('')
const [loading, setLoading] = useState(false)
const [loadingBoard, setLoadingBoard] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const isEdit = !!boardId
useEffect(() => {
if (boardId) {
fetchBoard()
}
}, [boardId])
const fetchBoard = async () => {
setLoadingBoard(true)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`)
if (res.ok) {
const data = await res.json()
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
setLoadingBoard(false)
}
}
const handleSave = async () => {
if (!name.trim()) {
setToastMessage({ text: 'Введите название доски', type: 'error' })
return
}
setLoading(true)
try {
const url = boardId
? `/api/wishlist/boards/${boardId}`
: '/api/wishlist/boards'
const res = await authFetch(url, {
method: boardId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
invite_enabled: inviteEnabled
})
})
if (res.ok) {
const data = await res.json()
if (data.invite_url) {
setInviteURL(data.invite_url)
}
onSaved?.()
if (!boardId) {
// При создании возвращаемся назад
onNavigate('wishlist', { boardId: data.id })
} else {
// При редактировании возвращаемся на доску
onNavigate('wishlist', { boardId: boardId })
}
} else {
const err = await res.json()
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
setLoading(false)
}
}
// Функция для автоматической генерации ссылки при включении доступа
const generateInviteLink = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
setInviteURL(data.invite_url)
setInviteEnabled(true)
}
} catch (err) {
console.error('Error generating invite link:', err)
}
}
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteURL)
setCopied(true)
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setTimeout(() => setCopied(false), 2000)
}
const handleToggleInvite = async (enabled) => {
setInviteEnabled(enabled)
if (boardId && enabled && !inviteURL) {
// Автоматически генерируем ссылку при включении
await generateInviteLink()
} else if (boardId) {
// Просто обновляем статус
try {
await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_enabled: enabled })
})
} catch (err) {
console.error('Error updating invite status:', err)
}
}
}
const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
setIsDeleting(true)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'DELETE'
})
if (res.ok) {
onSaved?.()
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
onNavigate('wishlist', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
}
const handleClose = () => {
window.history.back()
}
if (loadingBoard) {
return (
<div className="board-form">
<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>
)
}
return (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
<div className="form-card">
<div className="form-group">
<label htmlFor="board-name">Название</label>
<input
id="board-name"
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Название доски"
/>
</div>
{isEdit && (
<>
{/* Настройки доступа */}
<div className="form-section">
<h3>Доступ по ссылке</h3>
<label className="toggle-field">
<input
type="checkbox"
checked={inviteEnabled}
onChange={e => handleToggleInvite(e.target.checked)}
/>
<span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span>
</label>
{inviteEnabled && inviteURL && (
<div className="invite-link-section">
<div className="invite-url-row">
<input
type="text"
className="invite-url-input"
value={inviteURL}
readOnly
/>
<button
className="copy-btn"
onClick={handleCopyLink}
title="Копировать ссылку"
>
{copied ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске
</p>
</div>
)}
</div>
{/* Список участников */}
<BoardMembers
boardId={boardId}
onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
</>
)}
<div className="form-actions">
<SubmitButton
onClick={handleSave}
loading={loading}
disabled={!name.trim()}
>
Сохранить
</SubmitButton>
{isEdit && (
<DeleteButton
onClick={handleDelete}
loading={isDeleting}
disabled={loading}
title="Удалить доску"
/>
)}
</div>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default BoardForm

View File

@@ -0,0 +1,199 @@
.board-join-preview {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: white;
}
.preview-loading p {
margin: 0;
font-size: 1.1rem;
}
.preview-card {
background: white;
border-radius: 24px;
padding: 2rem;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.preview-card.error-card {
max-width: 360px;
}
.invite-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.preview-card h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.board-info {
background: #f9fafb;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.board-name {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.75rem;
}
.board-owner,
.board-members {
display: flex;
justify-content: center;
gap: 0.5rem;
font-size: 0.95rem;
color: #6b7280;
margin-top: 0.5rem;
}
.board-owner .value,
.board-members .value {
color: #374151;
font-weight: 500;
}
.error-text {
color: #ef4444;
margin: 0 0 1.5rem 0;
}
.join-error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #ef4444;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.join-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.join-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4);
}
.join-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.spinner-small {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.login-prompt {
text-align: center;
}
.login-prompt p {
color: #6b7280;
margin: 0 0 1rem 0;
font-size: 0.95rem;
}
.login-btn {
width: 100%;
padding: 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover {
background: #4f46e5;
}
.back-btn {
width: 100%;
padding: 1rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
background: #e5e7eb;
}
.cancel-link {
margin-top: 1rem;
background: none;
border: none;
color: #9ca3af;
font-size: 0.95rem;
cursor: pointer;
transition: color 0.2s;
}
.cancel-link:hover {
color: #6b7280;
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardJoinPreview.css'
function BoardJoinPreview({ inviteToken, onNavigate }) {
const { authFetch, user } = useAuth()
const [board, setBoard] = useState(null)
const [loading, setLoading] = useState(true)
const [joining, setJoining] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (inviteToken) {
fetchBoardInfo()
}
}, [inviteToken])
const fetchBoardInfo = async () => {
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}`)
if (res.ok) {
setBoard(await res.json())
} else {
const err = await res.json()
setError(err.error || 'Ссылка недействительна или устарела')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleJoin = async () => {
if (!user) {
// Сохраняем токен для возврата после логина
sessionStorage.setItem('pendingInviteToken', inviteToken)
onNavigate('login')
return
}
setJoining(true)
setError('')
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}/join`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
// Переходим на доску
onNavigate('wishlist', { boardId: data.board.id })
} else {
const err = await res.json()
setError(err.error || 'Ошибка при присоединении')
}
} catch (err) {
setError('Ошибка при присоединении')
} finally {
setJoining(false)
}
}
const handleGoBack = () => {
onNavigate('wishlist')
}
if (loading) {
return (
<div className="board-join-preview">
<div className="preview-loading">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !board) {
return (
<div className="board-join-preview">
<div className="preview-card error-card">
<div className="error-icon"></div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="back-btn" onClick={handleGoBack}>
Вернуться к желаниям
</button>
</div>
</div>
)
}
return (
<div className="board-join-preview">
<div className="preview-card">
<div className="invite-icon"></div>
<h2>Приглашение на доску</h2>
<div className="board-info">
<div className="board-name">{board.name}</div>
<div className="board-owner">
<span className="label">Владелец:</span>
<span className="value">{board.owner_name}</span>
</div>
{board.member_count > 0 && (
<div className="board-members">
<span className="label">Участников:</span>
<span className="value">{board.member_count}</span>
</div>
)}
</div>
{error && (
<div className="join-error">{error}</div>
)}
{user ? (
<button
className="join-btn"
onClick={handleJoin}
disabled={joining}
>
{joining ? (
<>
<span className="spinner-small"></span>
<span>Присоединение...</span>
</>
) : (
<>
<span>🎉</span>
<span>Присоединиться</span>
</>
)}
</button>
) : (
<div className="login-prompt">
<p>Для присоединения необходимо войти в аккаунт</p>
<button className="login-btn" onClick={() => onNavigate('login')}>
Войти
</button>
</div>
)}
<button className="cancel-link" onClick={handleGoBack}>
Отмена
</button>
</div>
</div>
)
}
export default BoardJoinPreview

View File

@@ -0,0 +1,132 @@
.board-members-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.board-members-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.members-loading {
padding: 1rem;
text-align: center;
color: #9ca3af;
}
.no-members {
padding: 1.5rem;
text-align: center;
background: #f9fafb;
border-radius: 8px;
}
.no-members p {
margin: 0;
color: #6b7280;
}
.no-members .hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #9ca3af;
}
.members-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
transition: background 0.15s;
}
.member-item:hover {
background: #f3f4f6;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-weight: 500;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-date {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 2px;
}
.remove-member-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid #e5e7eb;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-member-btn:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
color: #ef4444;
}
.remove-member-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner-small {
width: 14px;
height: 14px;
border: 2px solid #d1d5db;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardMembers.css'
function BoardMembers({ boardId, onMemberRemoved }) {
const { authFetch } = useAuth()
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(true)
const [removingId, setRemovingId] = useState(null)
useEffect(() => {
if (boardId) {
fetchMembers()
}
}, [boardId])
const fetchMembers = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`)
if (res.ok) {
const data = await res.json()
setMembers(data || [])
}
} catch (err) {
console.error('Error fetching members:', err)
} finally {
setLoading(false)
}
}
const handleRemoveMember = async (userId) => {
if (!window.confirm('Удалить участника из доски?')) return
setRemovingId(userId)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, {
method: 'DELETE'
})
if (res.ok) {
setMembers(members.filter(m => m.user_id !== userId))
onMemberRemoved?.()
}
} catch (err) {
console.error('Error removing member:', err)
} finally {
setRemovingId(null)
}
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
}
if (loading) {
return (
<div className="board-members-section">
<h3>Участники</h3>
<div className="members-loading">Загрузка...</div>
</div>
)
}
return (
<div className="board-members-section">
<h3>Участники ({members.length})</h3>
{members.length === 0 ? (
<div className="no-members">
<p>Пока никто не присоединился к доске</p>
<p className="hint">Поделитесь ссылкой, чтобы пригласить участников</p>
</div>
) : (
<div className="members-list">
{members.map(member => (
<div key={member.id} className="member-item">
<div className="member-avatar">
{(member.name || member.email).charAt(0).toUpperCase()}
</div>
<div className="member-info">
<div className="member-name">
{member.name || member.email}
</div>
<div className="member-date">
Присоединился {formatDate(member.joined_at)}
</div>
</div>
<button
className="remove-member-btn"
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingId === member.user_id}
title="Удалить участника"
>
{removingId === member.user_id ? (
<span className="spinner-small"></span>
) : (
'✕'
)}
</button>
</div>
))}
</div>
)}
</div>
)
}
export default BoardMembers

View File

@@ -0,0 +1,261 @@
.board-selector {
position: relative;
max-width: 42rem;
margin: 0 auto;
padding-top: 0;
padding-bottom: 16px;
}
/* Дополнительный отступ сверху на больших экранах, чтобы соответствовать кнопке "Добавить" на экране задач */
@media (min-width: 768px) {
.board-selector {
margin-top: 0.5rem; /* 8px - разница между md:p-8 (32px) и md:p-6 (24px) */
}
}
.board-header {
display: flex;
gap: 12px;
align-items: center;
}
/* Основная кнопка-pill */
.board-pill {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 26px;
font-size: 17px;
font-weight: 500;
color: #1f2937;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.board-pill:hover:not(:disabled) {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
transform: translateY(-1px);
}
.board-pill:active:not(:disabled) {
transform: translateY(0);
}
.board-pill.open {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.board-pill:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shared-icon {
font-size: 16px;
flex-shrink: 0;
}
.board-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: #9ca3af;
flex-shrink: 0;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.chevron.rotated {
transform: rotate(180deg);
}
/* Кнопка действия (настройки/выход) */
.board-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #6b7280;
flex-shrink: 0;
}
.board-action-btn:hover {
background: #f9fafb;
color: #374151;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
transform: translateY(-1px);
}
.board-action-btn:active {
transform: translateY(0);
}
.board-action-btn svg {
width: 22px;
height: 22px;
}
/* Выпадающий список */
.board-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: white;
border-radius: 18px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
z-index: 100;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.98);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.board-dropdown.visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.dropdown-content {
padding: 10px;
}
.dropdown-list {
max-height: 280px;
overflow-y: auto;
}
.dropdown-empty {
padding: 28px 16px;
text-align: center;
color: #9ca3af;
font-size: 15px;
}
/* Элементы списка */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 16px;
border: none;
background: transparent;
border-radius: 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
color: #374151;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-item.selected {
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
color: #4f46e5;
}
.dropdown-item.selected:hover {
background: linear-gradient(135deg, #667eea18 0%, #764ba218 100%);
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.item-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.item-badge {
font-size: 14px;
}
.item-members {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 8px;
border-radius: 13px;
font-size: 13px;
font-weight: 600;
border: none;
box-shadow: none;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}
.item-members.filled {
background: #e5e7eb;
color: #6b7280;
}
.item-members.outline {
background: transparent !important;
border: 1px solid #e5e7eb !important;
border-image: none !important;
outline: none !important;
box-shadow: none !important;
-webkit-box-shadow: none !important;
color: #6b7280 !important;
}
.check-icon {
color: #4f46e5;
}
/* Кнопка добавления доски */
.dropdown-item.add-board {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid #f3f4f6;
border-radius: 0 0 12px 12px;
color: #667eea;
font-weight: 500;
gap: 12px;
justify-content: flex-start;
}
.dropdown-item.add-board:hover {
background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
}
.dropdown-item.add-board svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,121 @@
import React, { useState, useEffect, useRef } from 'react'
import './BoardSelector.css'
function BoardSelector({
boards,
selectedBoardId,
onBoardChange,
onBoardEdit,
onAddBoard,
loading
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef(null)
const selectedBoard = boards.find(b => b.id === selectedBoardId)
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelectBoard = (board) => {
onBoardChange(board.id)
setIsOpen(false)
}
return (
<div className="board-selector" ref={dropdownRef}>
<div className="board-header">
<button
className={`board-pill ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen(!isOpen)}
disabled={loading}
>
<span className="board-label">
{loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')}
</span>
<svg
className={`chevron ${isOpen ? 'rotated' : ''}`}
width="14"
height="14"
viewBox="0 0 12 12"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{selectedBoard && (
<button
className="board-action-btn"
onClick={onBoardEdit}
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
>
{selectedBoard.is_owner ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
<circle cx="5" cy="12" r="1.5"></circle>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)}
</button>
)}
</div>
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
<div className="dropdown-content">
{boards.length === 0 ? (
<div className="dropdown-empty">
Нет досок
</div>
) : (
<div className="dropdown-list">
{boards.map(board => (
<button
key={board.id}
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
onClick={() => handleSelectBoard(board)}
>
<span className="item-name">{board.name}</span>
<div className="item-meta">
<span className={`item-members ${board.is_owner ? 'filled' : 'outline'}`}>{board.member_count + 1}</span>
</div>
</button>
))}
</div>
)}
<button className="dropdown-item add-board" onClick={onAddBoard}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span>Создать доску</span>
</button>
</div>
</div>
</div>
)
}
export default BoardSelector

View File

@@ -0,0 +1,63 @@
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
flex: 1;
height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-button {
background: #ef4444;
color: white;
padding: 0;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
width: 44px;
height: 44px;
box-sizing: border-box;
}
.delete-button:hover:not(:disabled) {
background: #dc2626;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,63 @@
import { PROJECT_COLORS_PALETTE } from '../utils/projectUtils'
import './Integrations.css'
function ColorPickerModal({ onClose, onColorSelect, currentColor }) {
const handleColorClick = (color) => {
onColorSelect(color)
onClose()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg max-w-md w-90 shadow-lg max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* Заголовок с кнопкой закрытия */}
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-800">Выберите цвет проекта</h3>
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Контент - сетка цветов */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-6 gap-3">
{PROJECT_COLORS_PALETTE.map((color, index) => (
<button
key={index}
onClick={() => handleColorClick(color)}
className={`
w-12 h-12 rounded-full
border-2 transition-all duration-200
hover:scale-110 hover:shadow-lg
${currentColor === color
? 'border-gray-800 shadow-md ring-2 ring-offset-2 ring-gray-400'
: 'border-gray-300 hover:border-gray-500'
}
`}
style={{ backgroundColor: color }}
title={color}
>
{currentColor === color && (
<div className="flex items-center justify-center h-full">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</button>
))}
</div>
</div>
</div>
</div>
)
}
export default ColorPickerModal

View File

@@ -0,0 +1,176 @@
/* Стили для модального окна добавления записи */
.add-entry-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: 9999;
padding: 1rem;
}
.add-entry-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: calc(100% - 2rem);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.add-entry-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem 0.5rem 1.5rem;
}
.add-entry-modal-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.add-entry-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.add-entry-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.add-entry-modal-content {
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
}
.add-entry-field {
margin-bottom: 0.5rem;
}
.add-entry-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.add-entry-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
}
.add-entry-textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.add-entry-rewards {
margin-bottom: 1.5rem;
}
.add-entry-reward-item {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
min-width: 0;
}
.add-entry-reward-item:last-child {
margin-bottom: 0;
}
.add-entry-reward-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
background: #f3f4f6;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #6b7280;
}
.add-entry-input {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
min-width: 0;
width: 100%;
}
.add-entry-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.add-entry-project-input {
flex: 3;
min-width: 0;
}
.add-entry-score-input {
flex: 1;
min-width: 0;
max-width: 100px;
}
.add-entry-submit-button {
width: 100%;
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.add-entry-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.add-entry-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,715 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import Toast from './Toast'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
import './CurrentWeek.css'
// Компонент круглого прогрессбара с использованием react-circular-progressbar
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
// Нормализуем прогресс для визуализации (0-100%)
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
// Определяем, достигнут ли 100% или выше
const isComplete = (displayProgress !== null ? displayProgress : progress) >= 100
// Определяем градиент ID: зелёный если >= 100%, иначе обычный градиент
const gradientId = isComplete ? 'success-gradient' : 'overall-gradient'
// Определяем класс размера текста
const textSizeClass = textSize === 'large' ? 'text-4xl' : textSize === 'small' ? 'text-base' : 'text-lg'
// Используем displayProgress если передан (может быть больше 100%), иначе progress
const progressToDisplay = displayProgress !== null ? displayProgress : progress
return (
<div className="relative" style={{ width: size, height: size }}>
<CircularProgressbar
value={normalizedProgress}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
// Цвета
pathColor: `url(#${gradientId})`,
trailColor: '#e5e7eb',
// Анимация
pathTransitionDuration: 1,
// Размер текста (убираем встроенный)
textSize: '0px',
// Поворот, чтобы пустая часть была снизу
rotation: 0.625,
strokeLinecap: 'round',
})}
// Создаем неполный круг (270 градусов)
circleRatio={0.75}
/>
{/* Иконка статистики в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<svg
width={size * 0.3}
height={size * 0.3}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: isComplete ? '#10b981' : '#4f46e5'
}}
>
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
</div>
{/* Кастомный текст снизу */}
<div className={`absolute inset-0 flex justify-center items-end ${textPosition === 'lower' ? '' : 'pb-2'}`} style={textPosition === 'lower' ? { bottom: '0.125rem' } : {}}>
<div className="text-center">
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
{/* Градиенты для SVG */}
<svg width="0" height="0">
<defs>
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
</svg>
</div>
)
}
// Компонент карточки проекта с круглым прогрессбаром
function ProjectCard({ project, projectColor, onProjectClick }) {
const { project_name, total_score, min_goal_score, max_goal_score, priority, today_change } = project
// Вычисляем прогресс по оригинальной логике из ProjectProgressBar
const getGoalProgress = () => {
const safeTotal = Number.isFinite(total_score) ? total_score : 0
const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
const priorityBonus = (() => {
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
if (safeMinGoal <= 0) {
if (safeMaxGoal > 0) {
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
}
return 0
}
// До достижения minGoal растем линейно от 0 до 100%
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
return baseProgress
}
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
const extraRange = safeMaxGoal - safeMinGoal
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
const extraProgress = extraRatio * priorityBonus
// Выше maxGoal прогресс не растет
return Math.min(100 + priorityBonus, 100 + extraProgress)
}
const goalProgress = getGoalProgress()
// Для визуального отображения: круг показывает максимум 100%
const visualProgress = Math.min(goalProgress, 100)
// Вычисляем целевую зону
const getTargetZone = () => {
const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
if (safeMinGoal > 0 && safeMaxGoal > 0) {
return `${safeMinGoal.toFixed(0)} - ${safeMaxGoal.toFixed(0)}`
} else if (safeMinGoal > 0) {
return `${safeMinGoal.toFixed(0)}+`
}
return '0+'
}
// Форматируем сегодняшний прирост
const formatTodayChange = (value) => {
if (value === null || value === undefined) return '0'
const rounded = Math.round(value * 10) / 10
if (rounded === 0) return '0'
if (Number.isInteger(rounded)) {
return rounded > 0 ? `+${rounded}` : `${rounded}`
}
return rounded > 0 ? `+${rounded.toFixed(1)}` : `${rounded.toFixed(1)}`
}
const handleClick = () => {
if (onProjectClick) {
onProjectClick(project_name)
}
}
return (
<div
onClick={handleClick}
className="bg-white rounded-3xl py-3 px-4 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300"
>
{/* Верхняя часть с названием и прогрессом */}
<div className="flex items-center justify-between">
{/* Левая часть - текст (название, баллы, целевая зона) */}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-600 leading-normal truncate mb-0.5">
{project_name}
</div>
<div className="flex items-center gap-2 mb-0.5">
<div className="text-3xl font-bold text-black leading-normal">
{total_score?.toFixed(1) || '0.0'}
</div>
{today_change !== null && today_change !== undefined && today_change !== 0 && (
<div className="text-base font-medium text-gray-400 leading-normal">
({formatTodayChange(today_change)})
</div>
)}
</div>
<div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()}
</div>
</div>
{/* Правая часть - круглый прогрессбар */}
<div className="flex-shrink-0 ml-3">
<CircularProgressBar
progress={visualProgress}
size={80}
strokeWidth={8}
textSize="small"
displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/>
</div>
</div>
</div>
)
}
// Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick }) {
if (projects.length === 0) return null
return (
<div>
{/* Заголовок группы */}
<div className="flex items-center gap-2 mb-3">
<h2 className="text-xl text-black">{title}</h2>
<span className="text-black text-xl font-bold"></span>
<span className="text-lg font-bold text-black">{subtitle}</span>
</div>
{/* Карточки проектов */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{projects.map((project, index) => {
if (!project || !project.project_name) return null
const projectColor = getProjectColor(project.project_name, allProjects, project.color)
return (
<ProjectCard
key={index}
project={project}
projectColor={projectColor}
onProjectClick={onProjectClick}
/>
)
})}
</div>
</div>
)
}
// Компонент модального окна для добавления записи
function AddEntryModal({ onClose, onSuccess, authFetch, setToastMessage }) {
const [message, setMessage] = useState('')
const [rewards, setRewards] = useState([])
const [projects, setProjects] = useState([])
const [isSending, setIsSending] = useState(false)
const debounceTimer = useRef(null)
// Загрузка списка проектов для автокомплита
useEffect(() => {
const loadProjects = async () => {
try {
const response = await authFetch('/projects')
if (response.ok) {
const data = await response.json()
setProjects(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading projects:', err)
}
}
loadProjects()
}, [authFetch])
// Функция поиска максимального индекса плейсхолдера
const findMaxPlaceholderIndex = (msg) => {
if (!msg) return -1
const indices = []
// Ищем ${N}
const matchesCurly = msg.match(/\$\{(\d+)\}/g) || []
matchesCurly.forEach(match => {
const numMatch = match.match(/\d+/)
if (numMatch) indices.push(parseInt(numMatch[0]))
})
// Ищем $N (но не \$N)
let searchIndex = 0
while (true) {
const index = msg.indexOf('$', searchIndex)
if (index === -1) break
if (index === 0 || msg[index - 1] !== '\\') {
const afterDollar = msg.substring(index + 1)
const digitMatch = afterDollar.match(/^(\d+)/)
if (digitMatch) {
indices.push(parseInt(digitMatch[0]))
}
}
searchIndex = index + 1
}
return indices.length > 0 ? Math.max(...indices) : -1
}
// Пересчет rewards при изменении сообщения (debounce 500ms)
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
const maxIndex = findMaxPlaceholderIndex(message)
setRewards(prevRewards => {
const currentRewards = [...prevRewards]
// Удаляем лишние
while (currentRewards.length > maxIndex + 1) {
currentRewards.pop()
}
// Добавляем недостающие
while (currentRewards.length < maxIndex + 1) {
currentRewards.push({
position: currentRewards.length,
project_name: '',
value: '0'
})
}
return currentRewards
})
}, 500)
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [message])
const handleRewardChange = (index, field, value) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], [field]: value }
setRewards(newRewards)
}
// Формирование финального сообщения с заменой плейсхолдеров
const buildFinalMessage = () => {
let result = message
// Формируем строки замены для каждого reward
const rewardStrings = {}
rewards.forEach((reward, index) => {
const score = parseFloat(reward.value) || 0
const projectName = reward.project_name.trim()
if (!projectName) return
const scoreStr = score >= 0
? `**${projectName}+${score}**`
: `**${projectName}${score}**`
rewardStrings[index] = scoreStr
})
// Заменяем ${N}
for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}`
if (rewardStrings[i]) {
result = result.split(placeholder).join(rewardStrings[i])
}
}
// Заменяем $N (с конца, чтобы $10 не заменился раньше $1)
for (let i = 99; i >= 0; i--) {
if (rewardStrings[i]) {
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
result = result.replace(regex, rewardStrings[i])
}
}
return result
}
// Проверка валидности формы: все поля проект+баллы должны быть заполнены
const isFormValid = () => {
if (rewards.length === 0) return true // Если нет полей, форма валидна
return rewards.every(reward => {
const projectName = reward.project_name?.trim() || ''
const value = reward.value?.toString().trim() || ''
return projectName !== '' && value !== ''
})
}
const handleSubmit = async () => {
// Валидация: все проекты должны быть заполнены
for (const reward of rewards) {
if (!reward.project_name.trim()) {
setToastMessage({ text: 'Заполните все проекты', type: 'error' })
return
}
}
const finalMessage = buildFinalMessage()
if (!finalMessage.trim()) {
setToastMessage({ text: 'Введите сообщение', type: 'error' })
return
}
setIsSending(true)
try {
const response = await authFetch('/message/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalMessage })
})
if (!response.ok) {
throw new Error('Ошибка при отправке')
}
setToastMessage({ text: 'Запись добавлена', type: 'success' })
onSuccess()
} catch (err) {
console.error('Error sending message:', err)
setToastMessage({ text: err.message || 'Ошибка при отправке', type: 'error' })
} finally {
setIsSending(false)
}
}
const modalContent = (
<div className="add-entry-modal-overlay" onClick={onClose}>
<div className="add-entry-modal" onClick={(e) => e.stopPropagation()}>
<div className="add-entry-modal-header">
<h2 className="add-entry-modal-title">Добавить запись</h2>
<button onClick={onClose} className="add-entry-close-button"></button>
</div>
<div className="add-entry-modal-content">
{/* Поле ввода сообщения */}
<div className="add-entry-field">
<label className="add-entry-label">Сообщение</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Используйте $0, $1 для указания проектов"
className="add-entry-textarea"
rows={3}
/>
</div>
{/* Динамические поля проект+баллы */}
{rewards.length > 0 && (
<div className="add-entry-rewards">
{rewards.map((reward, index) => (
<div key={index} className="add-entry-reward-item">
<span className="add-entry-reward-number">{index}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
placeholder="Проект"
className="add-entry-input add-entry-project-input"
list={`add-entry-projects-${index}`}
/>
<datalist id={`add-entry-projects-${index}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => handleRewardChange(index, 'value', e.target.value)}
placeholder="Баллы"
className="add-entry-input add-entry-score-input"
/>
</div>
))}
</div>
)}
{/* Кнопка отправки */}
<button
onClick={handleSubmit}
disabled={isSending || !isFormValid()}
className="add-entry-submit-button"
>
{isSending ? 'Отправка...' : 'Отправить'}
</button>
</div>
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate, onOpenAddModal }) {
const { authFetch } = useAuth()
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
// Экспортируем функцию открытия модала для использования из App.jsx
useEffect(() => {
if (onOpenAddModal) {
const openFn = () => {
setIsAddModalOpen(true)
}
onOpenAddModal(openFn)
}
}, [onOpenAddModal])
// Функция для обновления данных после добавления записи
const refreshData = () => {
if (onRetry) {
onRetry()
}
}
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<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>
)
}
if (error && (!data || projectsData.length === 0)) {
return <LoadingError onRetry={onRetry} />
}
// Процент выполнения берем только из данных API
const overallProgress = (() => {
// Проверяем различные возможные названия поля
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
}
return null // null означает, что данные не пришли
})()
const hasProgressData = overallProgress !== null
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
const normalizePriority = (value) => {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Группируем проекты по приоритетам
const priorityGroups = {
main: [], // priority === 1
important: [], // priority === 2
others: [] // остальные
}
if (projectsData && projectsData.length > 0) {
projectsData.forEach(project => {
if (!project || !project.project_name) return
const priority = normalizePriority(project.priority)
if (priority === 1) {
priorityGroups.main.push(project)
} else if (priority === 2) {
priorityGroups.important.push(project)
} else {
priorityGroups.others.push(project)
}
})
// Сортируем внутри каждой группы по min_goal_score по убыванию
Object.values(priorityGroups).forEach(group => {
group.sort((a, b) => {
const minGoalA = parseFloat(a.min_goal_score) || 0
const minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
})
}
// Получаем проценты групп из API данных
const mainProgress = (() => {
const rawValue = data?.group_progress_1
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const importantProgress = (() => {
const rawValue = data?.group_progress_2
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const othersProgress = (() => {
const rawValue = data?.group_progress_0
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
// Используем общий прогресс из API данных
const displayOverallProgress = overallProgress
return (
<div className="relative pt-8">
{/* Кнопка "Приоритеты" в правом верхнем углу */}
{onNavigate && (
<div className="absolute top-0 right-0 z-10">
<button
onClick={() => onNavigate('priorities')}
className="flex items-center justify-center w-10 h-10 text-gray-600 hover:text-indigo-600 transition-colors duration-200"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z"></path>
</svg>
</button>
</div>
)}
{/* Общий прогресс - большой круг в центре */}
<div className="flex flex-col items-center mb-6">
<div className="relative mb-6 cursor-pointer" onClick={() => onNavigate && onNavigate('full')}>
<CircularProgressBar
progress={displayOverallProgress !== null ? Math.min(displayOverallProgress, 100) : 0}
size={180}
strokeWidth={12}
showCheckmark={true}
displayProgress={displayOverallProgress}
/>
{/* Подсказка при наведении */}
<div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center">
<span className="text-xs text-gray-600 font-medium bg-white px-2 py-1 rounded shadow-sm">
Открыть статистику
</span>
</div>
</div>
</div>
{/* Группы проектов по приоритетам */}
<div className="space-y-6" style={{ paddingBottom: '5rem' }}>
<PriorityGroup
title="Главный"
subtitle={`${Math.round(mainProgress)}%`}
projects={priorityGroups.main}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Важные"
subtitle={`${Math.round(importantProgress)}%`}
projects={priorityGroups.important}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Остальные"
subtitle={`${Math.round(othersProgress)}%`}
projects={priorityGroups.others}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
</div>
{/* Модальное окно добавления записи */}
{isAddModalOpen && (
<AddEntryModal
onClose={() => setIsAddModalOpen(false)}
onSuccess={() => {
setIsAddModalOpen(false)
refreshData()
}}
authFetch={authFetch}
setToastMessage={setToastMessage}
/>
)}
{/* Toast уведомления */}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default CurrentWeek

View File

@@ -0,0 +1,29 @@
import React from 'react'
import './Buttons.css'
function DeleteButton({ loading, disabled, onClick, title = 'Удалить', ...props }) {
return (
<button
type="button"
className="delete-button"
onClick={onClick}
disabled={disabled || loading}
title={title}
{...props}
>
{loading ? (
<span>...</span>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
)
}
export default DeleteButton

View File

@@ -0,0 +1,224 @@
.dictionary-list {
padding-top: 0;
position: relative;
padding-bottom: 5rem;
}
.dictionary-close-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);
}
.dictionary-close-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.dictionaries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding-top: 4rem;
margin-bottom: 1rem;
}
.dictionary-card {
background: white;
border: 1px solid #ddd;
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;
}
.dictionary-list .dictionary-card .dictionary-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: #2c3e50 !important;
font-weight: bold;
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
}
.dictionary-list .dictionary-card .dictionary-menu-button:hover {
opacity: 0.7;
transform: scale(1.1);
}
.dictionary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dictionary-words-count {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.dictionary-name {
font-size: 1rem;
font-weight: 500;
color: #2c3e50;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.add-dictionary-button {
background: transparent;
border: 2px dashed #2c3e50;
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-dictionary-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.2);
background-color: rgba(44, 62, 80, 0.05);
border-color: #1a252f;
}
.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-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,155 @@
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, dictionaryName: dict.name })
}
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)
}
return (
<div className="dictionary-list">
{/* Кнопка закрытия */}
<button
className="dictionary-close-button"
onClick={() => window.history.back()}
title="Закрыть"
>
</button>
{/* Показываем загрузку только при первой инициализации и если нет данных для отображения */}
{loading && !isInitializedRef.current && dictionaries.length === 0 ? (
<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>
) : error ? (
<LoadingError onRetry={fetchDictionaries} />
) : (
<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>
))}
</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

@@ -0,0 +1,528 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css'
function FitbitIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const [oauthError, setOauthError] = useState('')
const [toastMessage, setToastMessage] = useState(null)
const [isLoadingError, setIsLoadingError] = useState(false)
const [goals, setGoals] = useState({
steps: { min: 8000, max: 10000 },
floors: { min: 8, max: 10 },
azm: { min: 22, max: 44 }
})
const [stats, setStats] = useState({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
const [isEditingGoals, setIsEditingGoals] = useState(false)
const [editedGoals, setEditedGoals] = useState(goals)
const [syncing, setSyncing] = useState(false)
// Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus
const oauthStatusRef = React.useRef(null)
useEffect(() => {
// Проверяем URL параметры для сообщений ДО вызова checkStatus
const params = new URLSearchParams(window.location.search)
const integration = params.get('integration')
const status = params.get('status')
if (integration === 'fitbit') {
oauthStatusRef.current = status
if (status === 'connected') {
setMessage('Fitbit успешно подключен!')
} else if (status === 'error') {
const errorMsg = params.get('message') || 'unknown_error'
const errorMessages = {
'config_error': 'Ошибка конфигурации сервера. Обратитесь к администратору.',
'invalid_state': 'Недействительный токен авторизации. Попробуйте ещё раз.',
'no_code': 'Не получен код авторизации от Fitbit. Попробуйте ещё раз.',
'token_exchange_failed': 'Не удалось обменять код на токен. Проверьте настройки Fitbit приложения.',
'user_info_failed': 'Не удалось получить информацию о пользователе Fitbit.',
'db_error': 'Ошибка сохранения данных. Попробуйте ещё раз.',
'unknown_error': 'Произошла неизвестная ошибка при подключении Fitbit.'
}
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
}
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname)
}
checkStatus()
}, [])
useEffect(() => {
if (connected) {
loadStats()
}
}, [connected])
const checkStatus = async () => {
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/status')
if (!response.ok) {
throw new Error('Ошибка при проверке статуса')
}
const data = await response.json()
setConnected(data.connected || false)
if (data.connected && data.goals) {
setGoals(data.goals)
setEditedGoals(data.goals)
}
// Если OAuth вернул status=connected, но бэкенд не подтвердил подключение
if (oauthStatusRef.current === 'connected' && !data.connected) {
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.')
setMessage('')
}
oauthStatusRef.current = null
} catch (error) {
console.error('Error checking status:', error)
setError(error.message || 'Не удалось проверить статус')
setIsLoadingError(true)
} finally {
setLoading(false)
}
}
const loadStats = async () => {
try {
const response = await authFetch('/api/integrations/fitbit/stats')
if (!response.ok) {
throw new Error('Ошибка при загрузке статистики')
}
const data = await response.json()
// Нормализуем данные, чтобы избежать undefined
const defaultGoal = { min: 0, max: 0 }
const normalizedStats = {
steps: {
value: data.steps?.value ?? 0,
goal: data.steps?.goal ?? defaultGoal
},
floors: {
value: data.floors?.value ?? 0,
goal: data.floors?.goal ?? defaultGoal
},
azm: {
value: data.azm?.value ?? 0,
goal: data.azm?.goal ?? defaultGoal
}
}
setStats(normalizedStats)
// Обновляем цели из ответа
if (data.steps?.goal) {
setGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
})
setEditedGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
})
}
} catch (error) {
console.error('Error loading stats:', error)
// Не показываем ошибку, просто не обновляем статистику
}
}
const handleConnect = async () => {
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/oauth/connect')
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при подключении Fitbit')
}
const data = await response.json()
if (data.auth_url) {
window.location.href = data.auth_url
} else {
throw new Error('URL для авторизации не получен')
}
} catch (error) {
console.error('Error connecting Fitbit:', error)
setToastMessage({ text: error.message || 'Не удалось подключить Fitbit', type: 'error' })
setLoading(false)
}
}
const handleDisconnect = async () => {
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
return
}
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/disconnect', {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при отключении')
}
setConnected(false)
setStats({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
} catch (error) {
console.error('Error disconnecting:', error)
setToastMessage({ text: error.message || 'Не удалось отключить Fitbit', type: 'error' })
} finally {
setLoading(false)
}
}
const handleSync = async () => {
try {
setSyncing(true)
const response = await authFetch('/api/integrations/fitbit/sync', {
method: 'POST',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при синхронизации')
}
setToastMessage({ text: 'Данные синхронизированы', type: 'success' })
await loadStats()
} catch (error) {
console.error('Error syncing:', error)
setToastMessage({ text: error.message || 'Не удалось синхронизировать данные', type: 'error' })
} finally {
setSyncing(false)
}
}
const handleSaveGoals = async () => {
try {
const response = await authFetch('/api/integrations/fitbit/goals', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
steps: editedGoals.steps,
floors: editedGoals.floors,
azm: editedGoals.azm,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении целей')
}
setGoals(editedGoals)
setIsEditingGoals(false)
setToastMessage({ text: 'Цели сохранены', type: 'success' })
await loadStats()
} catch (error) {
console.error('Error saving goals:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' })
}
}
const handleCancelEdit = () => {
setEditedGoals(goals)
setIsEditingGoals(false)
}
const getProgressPercent = (value, min, max) => {
if (value >= max) return 100
if (value <= min) return (value / min) * 50
return 50 + ((value - min) / (max - min)) * 50
}
const getProgressColor = (value, min, max) => {
if (value >= max) return 'text-green-600'
if (value >= min) return 'text-blue-600'
return 'text-gray-600'
}
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="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1>
{loading ? (
<div className="fixed inset-0 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>
) : connected ? (
<div>
{message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p>
</div>
)}
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-1">Скрыть</button>
</div>
)}
{/* Статистика */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
</div>
{/* Шаги */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Шаги</span>
<span className={`font-bold ${getProgressColor(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0)}`}>
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Этажи */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Этажи</span>
<span className={`font-bold ${getProgressColor(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0)}`}>
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Баллы кардио (AZM) */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Баллы кардио</span>
<span className={`font-bold ${getProgressColor(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0)}`}>
{stats.azm?.value ?? 0} / {stats.azm?.goal?.min ?? 0}-{stats.azm?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
</div>
{/* Настройка целей */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Дневные цели</h2>
{!isEditingGoals && (
<button
onClick={() => setIsEditingGoals(true)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
>
Изменить
</button>
)}
</div>
{isEditingGoals ? (
<div className="space-y-4">
{/* Шаги */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.steps.min}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.steps.max}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Этажи */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.floors.min}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.floors.max}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Баллы кардио */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.azm.min}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.azm.max}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveGoals}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Сохранить
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Отмена
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Шаги:</span>
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Этажи:</span>
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Баллы кардио:</span>
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
</div>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Как это работает
</h3>
<p className="text-gray-700 mb-2">
Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
</p>
<p className="text-gray-600 text-sm">
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
</p>
</div>
<button
onClick={handleDisconnect}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Отключить Fitbit
</button>
</div>
) : (
<div>
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-medium">Ошибка подключения Fitbit</p>
<p className="text-red-700 mt-1">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-2">Скрыть</button>
</div>
)}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
<p className="text-gray-700 mb-4">
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки.
</p>
<button
onClick={handleConnect}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
Подключить Fitbit
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Что нужно сделать
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Fitbit"</li>
<li>Авторизуйтесь в Fitbit</li>
<li>Разрешите доступ к данным о физической активности</li>
<li>Готово! Данные будут синхронизироваться автоматически</li>
</ol>
</div>
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default FitbitIntegration

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect, useCallback } from 'react'
import WeekProgressChart from './WeekProgressChart'
import LoadingError from './LoadingError'
import TodayEntriesList from './TodayEntriesList'
import { getAllProjectsSorted } from '../utils/projectUtils'
import './Integrations.css'
// Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils'
// Функция для получения дат текущей недели (понедельник - воскресенье)
const getCurrentWeekDates = () => {
const now = new Date()
const day = now.getDay()
// Вычисляем разницу до понедельника (1 = понедельник, 0 = воскресенье)
const diff = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diff)
const dates = []
for (let i = 0; i < 7; i++) {
const date = new Date(monday)
date.setDate(monday.getDate() + i)
dates.push(date)
}
return dates
}
// Функция для форматирования даты в YYYY-MM-DD
const formatDate = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Названия дней недели
const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries, activeTab }) {
const [selectedDate, setSelectedDate] = useState(null)
const prevActiveTabRef = React.useRef(activeTab)
const componentJustOpenedRef = React.useRef(false)
// Получаем даты текущей недели
const weekDates = getCurrentWeekDates()
// Определяем текущий день (используем useMemo для стабильности)
const today = React.useMemo(() => {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date
}, [])
// Получаем строковое представление сегодняшней даты
const todayDateStr = React.useMemo(() => formatDate(today), [today])
// Фильтруем только прошедшие дни (включая сегодня)
const pastDays = weekDates.filter((date) => {
const dateOnly = new Date(date)
dateOnly.setHours(0, 0, 0, 0)
return dateOnly <= today
})
// Отслеживаем выход с экрана и сбрасываем выбор дня
useEffect(() => {
// Если мы были на экране full и перешли на другой экран - сбрасываем выбор дня
if (prevActiveTabRef.current === 'full' && activeTab !== 'full') {
setSelectedDate(todayDateStr)
}
}, [activeTab, todayDateStr])
// Инициализируем выбранную дату текущим днем при первом рендере
// Также проверяем, что выбранная дата все еще в списке доступных дней
useEffect(() => {
const pastDaysDateStrs = pastDays.map(date => formatDate(date))
if (selectedDate === null) {
// Первая инициализация - устанавливаем текущий день
setSelectedDate(todayDateStr)
} else if (!pastDaysDateStrs.includes(selectedDate)) {
// Если выбранная дата больше не в списке доступных (например, прошла неделя)
// Сбрасываем на текущий день
setSelectedDate(todayDateStr)
}
}, [selectedDate, todayDateStr, pastDays])
// Отслеживаем открытие компонента
useEffect(() => {
// Когда компонент открывается (activeTab становится 'full'), помечаем это
if (activeTab === 'full' && prevActiveTabRef.current !== 'full') {
componentJustOpenedRef.current = true
}
prevActiveTabRef.current = activeTab
}, [activeTab])
// Загружаем данные при изменении selectedDate или selectedProject
useEffect(() => {
if (selectedDate && fetchTodayEntries) {
// Если компонент только что открылся - используем фоновую загрузку
if (componentJustOpenedRef.current) {
componentJustOpenedRef.current = false
fetchTodayEntries(true, selectedProject, selectedDate)
} else {
// При изменении даты или проекта - используем обычную загрузку (не фоновую)
fetchTodayEntries(false, selectedProject, selectedDate)
}
}
}, [selectedDate, selectedProject, fetchTodayEntries])
// Обработчик выбора дня
const handleDaySelect = useCallback((date) => {
const dateStr = formatDate(date)
setSelectedDate(dateStr)
// Загрузка данных произойдет автоматически через useEffect выше
}, [])
if (error && (!data || data.length === 0) && !loading) {
return <LoadingError onRetry={onRetry} />
}
return (
<div className="max-w-2xl mx-auto">
{onNavigate && (
<button
onClick={() => {
// Сбрасываем выбор дня перед выходом с экрана
setSelectedDate(todayDateStr)
window.history.back()
}}
className="close-x-button"
title="Закрыть"
>
</button>
)}
{loading && (!data || data.length === 0) && (!todayEntries || (Array.isArray(todayEntries) && todayEntries.length === 0)) ? (
<div className="fixed inset-0 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>
) : (!data || data.length === 0) && !todayEntries ? (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
) : (
<>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
{/* Чипсы дней недели */}
{pastDays.length > 0 && (
<div className="mt-3 mb-2">
<div className="flex flex-wrap gap-2.5">
{pastDays.map((date, index) => {
const dateStr = formatDate(date)
const dayOfWeek = index + 1 // 1 = понедельник
const isSelected = selectedDate === dateStr
const isToday = dateStr === todayDateStr
return (
<button
key={dateStr}
onClick={() => handleDaySelect(date)}
className={`
h-9 px-4 rounded-lg text-sm font-semibold
transition-all duration-200 ease-in-out
flex items-center justify-center
${
isSelected
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
: 'bg-transparent text-gray-700 border border-gray-300 hover:border-gray-400'
}
${isToday && !isSelected ? 'ring-2 ring-indigo-200 border-indigo-300' : ''}
active:scale-95
`}
>
{dayNames[dayOfWeek - 1]}
</button>
)
})}
</div>
</div>
)}
<TodayEntriesList
data={todayEntries}
loading={todayEntriesLoading}
error={todayEntriesError}
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
onDelete={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
/>
</>
)}
</div>
)
}
export default FullStatistics

View File

@@ -0,0 +1,25 @@
.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;
}

View 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);
}

View 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

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
export default function PWAUpdatePrompt() {
const [showPrompt, setShowPrompt] = useState(false)
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
onRegistered(r) {
console.log('SW зарегистрирован:', r)
},
onRegisterError(error) {
console.log('SW ошибка регистрации:', error)
}
})
useEffect(() => {
if (needRefresh) {
setShowPrompt(true)
}
}, [needRefresh])
const handleUpdate = () => {
updateServiceWorker(true)
setShowPrompt(false)
}
const handleDismiss = () => {
setNeedRefresh(false)
setShowPrompt(false)
}
if (!showPrompt) return null
return (
<div className="fixed bottom-24 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-4 z-50">
<p className="text-sm text-gray-700 mb-3">
Доступна новая версия приложения
</p>
<div className="flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 px-3 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"
>
Обновить
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200"
>
Позже
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import React from 'react'
import { useAuth } from './auth/AuthContext'
import packageJson from '../../package.json'
function Profile({ onNavigate }) {
const { user, logout } = useAuth()
const integrations = [
{ id: 'todoist-integration', name: 'TODOist' },
{ id: 'telegram-integration', name: 'Telegram' },
{ id: 'fitbit-integration', name: 'Fitbit' },
]
const handleLogout = async () => {
if (window.confirm('Вы уверены, что хотите выйти?')) {
await logout()
}
}
return (
<div className="max-w-2xl mx-auto">
{/* Profile Header */}
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center text-2xl font-bold backdrop-blur-sm">
{user?.name ? user.name.charAt(0).toUpperCase() : user?.email?.charAt(0).toUpperCase() || '?'}
</div>
<div className="flex-1">
<h1 className="text-xl font-bold">
{user?.name || 'Пользователь'}
</h1>
<p className="text-indigo-100 text-sm">
{user?.email}
</p>
</div>
</div>
</div>
{/* Admin & Tracking Buttons */}
<div className="mb-6">
<div className="space-y-3">
{user?.is_admin && (
<button
onClick={() => {
const adminUrl = window.location.origin + '/admin';
window.open(adminUrl, '_blank', 'noopener,noreferrer');
}}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-purple-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-purple-600 transition-colors">
Администрирование
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-purple-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
)}
<button
onClick={() => onNavigate?.('tracking')}
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="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
</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="M9 5l7 7-7 7"
/>
</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>
<div className="space-y-3">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => onNavigate?.(integration.id)}
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">
{integration.name}
</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="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
{/* Account Section */}
<div>
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Аккаунт</h2>
<button
onClick={handleLogout}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-red-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-red-600 transition-colors">
Выйти из аккаунта
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</div>
</button>
</div>
{/* Version Info */}
<div className="mt-8 text-center text-gray-400 text-sm">
<p>PlayLife v{packageJson.version}</p>
</div>
</div>
)
}
export default Profile

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
function ProjectProgressBar({ projectName, totalScore, minGoalScore, maxGoalScore, onProjectClick, projectColor, priority }) {
// Вычисляем максимальное значение для шкалы (берем максимум из maxGoalScore и totalScore + 20%)
const maxScale = Math.max(maxGoalScore, totalScore * 1.2, 1)
// Процентные значения
const totalScorePercent = (totalScore / maxScale) * 100
const minGoalPercent = (minGoalScore / maxScale) * 100
const maxGoalPercent = (maxGoalScore / maxScale) * 100
const goalRangePercent = maxGoalPercent - minGoalPercent
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
const priorityBonus = (() => {
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
const goalProgress = (() => {
const safeTotal = Number.isFinite(totalScore) ? totalScore : 0
const safeMinGoal = Number.isFinite(minGoalScore) ? minGoalScore : 0
const safeMaxGoal = Number.isFinite(maxGoalScore) ? maxGoalScore : 0
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
if (safeMinGoal <= 0) {
if (safeMaxGoal > 0) {
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
}
return 0
}
// До достижения minGoal растем линейно от 0 до 100%
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
return baseProgress
}
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
const extraRange = safeMaxGoal - safeMinGoal
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
const extraProgress = extraRatio * priorityBonus
// Выше maxGoal прогресс не растет
return Math.min(100 + priorityBonus, 100 + extraProgress)
})()
const isGoalReached = totalScore >= minGoalScore
const isGoalExceeded = totalScore >= maxGoalScore
const priorityBorderStyle =
normalizedPriority === 1
? { borderColor: '#d4af37' }
: normalizedPriority === 2
? { borderColor: '#c0c0c0' }
: {}
const cardBorderClasses =
normalizedPriority === 1 || normalizedPriority === 2
? 'border-2'
: 'border border-gray-200/50 hover:border-indigo-300'
const cardBaseClasses =
'bg-gradient-to-br from-white to-gray-50 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer'
const handleClick = () => {
if (onProjectClick) {
onProjectClick(projectName)
}
}
return (
<div
onClick={handleClick}
className={`${cardBaseClasses} ${cardBorderClasses}`}
style={priorityBorderStyle}
>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: projectColor }}
></div>
<h3 className="text-lg font-semibold text-gray-800">{projectName}</h3>
</div>
<div className="text-right">
<div className="text-lg font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{totalScore.toFixed(1)}
</div>
<div className="text-xs text-gray-500">
из {minGoalScore.toFixed(1)} ({goalProgress.toFixed(0)}%)
</div>
</div>
</div>
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{/* Диапазон цели (min_goal_score до max_goal_score) */}
{minGoalScore > 0 && maxGoalScore > 0 && (
<div
className="absolute h-full bg-gradient-to-r from-amber-200 via-yellow-300 to-amber-200 opacity-70 border-l border-r border-amber-400"
style={{
left: `${minGoalPercent}%`,
width: `${goalRangePercent}%`,
}}
title={`Цель: ${minGoalScore.toFixed(2)} - ${maxGoalScore.toFixed(2)}`}
/>
)}
{/* Текущее значение (total_score) */}
<div
className={`absolute h-full transition-all duration-700 ease-out ${
isGoalExceeded
? 'bg-gradient-to-r from-green-500 to-emerald-500'
: isGoalReached
? 'bg-gradient-to-r from-yellow-500 to-amber-500'
: 'bg-gradient-to-r from-indigo-500 to-purple-500'
} shadow-sm`}
style={{
width: `${totalScorePercent}%`,
}}
>
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
{/* Индикатор текущего значения */}
{totalScorePercent > 0 && (
<div
className="absolute top-0 h-full w-0.5 bg-white shadow-md"
style={{
left: `${totalScorePercent}%`,
}}
/>
)}
</div>
<div className="flex justify-between items-center text-xs mt-1.5">
<span className="text-gray-400">0</span>
{minGoalScore > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400"></div>
<span className="text-amber-700 font-medium text-xs">
Цель: {minGoalScore.toFixed(1)} - {maxGoalScore.toFixed(1)}
</span>
</div>
)}
<span className="text-gray-400">{maxScale.toFixed(1)}</span>
</div>
</div>
)
}
export default ProjectProgressBar

View File

@@ -0,0 +1,20 @@
import React from 'react'
import './Buttons.css'
function SubmitButton({ loading, disabled, children, onClick, type = 'button', ...props }) {
const displayText = loading ? 'Сохранение...' : (children || 'Сохранить')
return (
<button
type={type}
className="submit-button"
onClick={onClick}
disabled={disabled || loading}
{...props}
>
{displayText}
</button>
)
}
export default SubmitButton

View File

@@ -0,0 +1,429 @@
/* Модальное окно */
.task-detail-modal-overlay {
position: fixed !important;
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: 9999 !important;
padding: 1rem;
}
.task-detail-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-detail-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem 0.5rem 1.5rem;
}
.task-detail-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-detail-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-detail-modal-content {
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.task-detail-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
flex: 1;
min-width: 0;
word-wrap: break-word;
word-break: break-word;
transition: opacity 0.2s;
line-height: 1.5;
}
.task-detail-title:hover {
opacity: 0.7;
}
.task-detail-edit-icon {
color: #6b7280;
flex-shrink: 0;
transition: color 0.2s;
display: inline-block;
vertical-align: middle;
margin-left: 0.5rem;
}
.task-detail-title:hover .task-detail-edit-icon {
color: #1f2937;
}
.task-detail-auto-complete-icon {
color: #6366f1;
flex-shrink: 0;
}
.task-reward-message {
margin-bottom: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border-left: 3px solid #6366f1;
}
.reward-message-text {
color: #374151;
line-height: 1.6;
}
.reward-message-text strong {
color: #1f2937;
font-weight: 600;
}
.task-subtasks {
margin-bottom: 1rem;
}
.subtasks-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem 0;
}
.subtask-item {
margin-bottom: 0.5rem;
}
.subtask-checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.subtask-checkbox {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.subtask-content {
flex: 1;
}
.subtask-name {
font-weight: 500;
color: #1f2937;
}
.subtask-reward-message {
margin-top: 0.5rem;
padding: 0.75rem;
background: white;
border-radius: 0.25rem;
}
.progression-section {
margin-bottom: 1.5rem;
}
.progression-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.progression-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
}
.progression-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.task-detail-divider {
height: 1px;
background: #e5e7eb;
margin: 1.5rem 0;
}
.telegram-message-preview {
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border-left: 3px solid #6366f1;
}
.telegram-message-label {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.telegram-message-text {
color: #1f2937;
line-height: 1.6;
white-space: pre-wrap;
}
.telegram-message-text strong {
font-weight: 600;
color: #1f2937;
}
.task-actions-section {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.task-actions-bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0;
}
.task-action-left {
flex: 1;
display: flex;
min-width: 0;
}
.complete-at-end-of-day-checkbox {
margin-top: 0;
margin-bottom: 0.25rem;
}
.complete-at-end-of-day-checkbox .checkbox-label {
font-size: 0.85rem;
gap: 0.25rem;
}
.complete-at-end-of-day-checkbox .checkbox-input {
width: 1rem;
height: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
color: #374151;
user-select: none;
}
.checkbox-input {
width: 1.125rem;
height: 1.125rem;
cursor: pointer;
accent-color: #6366f1;
}
.checkbox-label:has(.checkbox-input:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.task-actions-buttons {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.task-action-complete-buttons {
flex: 1;
display: flex;
gap: 0.25rem;
align-items: stretch;
min-width: 0;
}
.action-button {
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.5;
height: calc(0.75rem * 2 + 1rem + 0.125rem * 2);
}
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-button-check {
flex: 1;
width: 100%;
min-width: 0;
height: calc(0.75rem * 2 + 1rem + 0.125rem * 2);
padding: 0.75rem 1.5rem;
box-sizing: border-box;
background: linear-gradient(to right, #6366f1, #8b5cf6);
margin: 0;
}
.action-button-check:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.action-button-double-check {
width: calc(0.75rem * 2 + 1rem + 0.125rem * 2);
min-width: calc(0.75rem * 2 + 1rem);
height: calc(0.75rem * 2 + 1rem + 0.125rem * 2);
padding: 0.75rem;
flex-shrink: 0;
box-sizing: border-box;
background: transparent;
border: 2px solid #10b981;
color: #10b981;
margin: 0;
}
.action-button-double-check:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.1);
}
.action-button-save {
flex: 1;
width: 100%;
min-width: 0;
background: linear-gradient(to right, #10b981, #059669);
}
.action-button-save:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.next-task-date-info {
font-size: 0.875rem;
color: #6b7280;
text-align: left;
margin-top: 0.25rem;
}
.next-task-date-bold {
font-weight: 600;
}
.loading,
.error-message {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.error-message {
color: #ef4444;
}
.task-wishlist-link {
margin-bottom: 1.5rem;
padding: 0.75rem;
background-color: #f0f9ff;
border-radius: 6px;
border: 1px solid #bae6fd;
}
.task-wishlist-link-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-wishlist-link-info svg {
color: #6366f1;
flex-shrink: 0;
}
.task-wishlist-link-label {
font-size: 0.9rem;
color: #374151;
font-weight: 500;
}
.task-wishlist-link-button {
background: none;
border: none;
color: #6366f1;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
text-decoration: underline;
margin-left: auto;
}
.task-wishlist-link-button:hover {
background-color: rgba(99, 102, 241, 0.1);
text-decoration: none;
}

View File

@@ -0,0 +1,319 @@
/* Модальное окно */
.task-detail-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: 1700;
padding: 1rem;
}
.task-detail-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-detail-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
}
.task-detail-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-detail-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-detail-modal-content {
padding: 0 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.task-detail-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.task-reward-message {
margin-bottom: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border-left: 3px solid #6366f1;
}
.reward-message-text {
color: #374151;
line-height: 1.6;
}
.reward-message-text strong {
color: #1f2937;
font-weight: 600;
}
.task-subtasks {
margin-bottom: 1rem;
}
.subtasks-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem 0;
}
.subtask-item {
margin-bottom: 0.5rem;
}
.subtask-checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.subtask-checkbox {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.subtask-content {
flex: 1;
}
.subtask-name {
font-weight: 500;
color: #1f2937;
}
.subtask-reward-message {
margin-top: 0.5rem;
padding: 0.75rem;
background: white;
border-radius: 0.25rem;
}
.progression-section {
margin-bottom: 1.5rem;
}
.progression-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.progression-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
box-sizing: border-box;
}
.progression-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.task-detail-divider {
height: 1px;
background: #e5e7eb;
margin: 1.5rem 0;
}
.telegram-message-preview {
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border-left: 3px solid #6366f1;
}
.telegram-message-label {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.telegram-message-text {
color: #1f2937;
line-height: 1.6;
white-space: pre-wrap;
}
.telegram-message-text strong {
font-weight: 600;
color: #1f2937;
}
.task-actions-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.task-actions-buttons {
display: flex;
gap: 0.75rem;
align-items: center;
}
.complete-button {
flex: 1;
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.complete-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.complete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.close-button-outline {
padding: 0.75rem;
background: transparent;
color: #6366f1;
border: 2px solid #6366f1;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
height: 2.75rem;
}
.close-button-outline:hover:not(:disabled) {
transform: translateY(-1px);
background: rgba(99, 102, 241, 0.1);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
.close-button-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.next-task-date-info {
font-size: 0.875rem;
color: #6b7280;
text-align: left;
margin-top: -0.125rem;
margin-bottom: -0.5rem;
}
.loading,
.error-message {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.error-message {
color: #ef4444;
}
.task-wishlist-link {
margin-bottom: 1.5rem;
padding: 0.75rem;
background-color: #f0f9ff;
border-radius: 6px;
border: 1px solid #bae6fd;
}
.task-wishlist-link-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-wishlist-link-info svg {
color: #6366f1;
flex-shrink: 0;
}
.task-wishlist-link-label {
font-size: 0.9rem;
color: #374151;
font-weight: 500;
}
.task-wishlist-link-button {
background: none;
border: none;
color: #6366f1;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
text-decoration: underline;
margin-left: auto;
}
.task-wishlist-link-button:hover {
background-color: rgba(99, 102, 241, 0.1);
text-decoration: none;
}

View File

@@ -0,0 +1,933 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './TaskDetail.css'
const API_URL = '/api/tasks'
// Функция для проверки, является ли период нулевым
const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false
const trimmed = intervalStr.trim()
const parts = trimmed.split(/\s+/)
if (parts.length < 1) return false
const value = parseInt(parts[0], 10)
return !isNaN(value) && value === 0
}
// Функция для проверки, является ли repetition_date нулевым
const isZeroDate = (dateStr) => {
if (!dateStr) return false
const trimmed = dateStr.trim()
const parts = trimmed.split(/\s+/)
if (parts.length < 2) return false
const value = parts[0]
const numValue = parseInt(value, 10)
return !isNaN(numValue) && numValue === 0
}
// Функция для вычисления следующей даты по repetition_date
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
if (!repetitionDateStr) return null
const parts = repetitionDateStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parts[0]
const unit = parts[1].toLowerCase()
const now = new Date()
now.setHours(0, 0, 0, 0)
switch (unit) {
case 'week': {
// N-й день недели (1=понедельник, 7=воскресенье)
const dayOfWeek = parseInt(value, 10)
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
// Наш формат: 1=понедельник... 7=воскресенье
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
const currentJsDay = now.getDay()
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
// Если сегодня тот же день, берём следующую неделю
if (daysUntil === 0) daysUntil = 7
const nextDate = new Date(now)
nextDate.setDate(now.getDate() + daysUntil)
return nextDate
}
case 'month': {
// N-й день месяца
const dayOfMonth = parseInt(value, 10)
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
// Ищем ближайшую дату с этим днём
let searchDate = new Date(now)
for (let i = 0; i < 12; i++) {
const year = searchDate.getFullYear()
const month = searchDate.getMonth()
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
const candidateDate = new Date(year, month, actualDay)
if (candidateDate > now) {
return candidateDate
}
// Переходим к следующему месяцу
searchDate = new Date(year, month + 1, 1)
}
return null
}
case 'year': {
// MM-DD формат
const dateParts = value.split('-')
if (dateParts.length !== 2) return null
const monthNum = parseInt(dateParts[0], 10)
const day = parseInt(dateParts[1], 10)
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
let year = now.getFullYear()
let candidateDate = new Date(year, monthNum - 1, day)
if (candidateDate <= now) {
candidateDate = new Date(year + 1, monthNum - 1, day)
}
return candidateDate
}
default:
return null
}
}
// Функция для вычисления следующей даты по repetition_period
// Поддерживает сокращенные формы единиц времени (например, "mons" для месяцев)
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
if (!repetitionPeriodStr) return null
const parts = repetitionPeriodStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parseInt(parts[0], 10)
if (isNaN(value) || value === 0) return null
const unit = parts[1].toLowerCase()
const now = new Date()
now.setHours(0, 0, 0, 0)
const nextDate = new Date(now)
switch (unit) {
case 'minute':
case 'minutes':
case 'mins':
case 'min':
nextDate.setMinutes(nextDate.getMinutes() + value)
break
case 'hour':
case 'hours':
case 'hrs':
case 'hr':
nextDate.setHours(nextDate.getHours() + value)
break
case 'day':
case 'days':
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
// Если количество дней кратно 7, обрабатываем как недели
if (value % 7 === 0 && value >= 7) {
const weeks = value / 7
nextDate.setDate(nextDate.getDate() + weeks * 7)
} else {
nextDate.setDate(nextDate.getDate() + value)
}
break
case 'week':
case 'weeks':
case 'wks':
case 'wk':
nextDate.setDate(nextDate.getDate() + value * 7)
break
case 'month':
case 'months':
case 'mons':
case 'mon':
nextDate.setMonth(nextDate.getMonth() + value)
break
case 'year':
case 'years':
case 'yrs':
case 'yr':
nextDate.setFullYear(nextDate.getFullYear() + value)
break
default:
return null
}
return nextDate
}
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
const formatDateToLocal = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Форматирование даты для отображения с понятными названиями
const formatDateForDisplay = (dateStr) => {
if (!dateStr) return ''
// Парсим дату из формата YYYY-MM-DD
const dateParts = dateStr.split('-')
if (dateParts.length !== 3) return dateStr
const yearNum = parseInt(dateParts[0], 10)
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
const dayNum = parseInt(dateParts[2], 10)
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
const targetDate = new Date(yearNum, monthNum, dayNum)
targetDate.setHours(0, 0, 0, 0)
const now = new Date()
now.setHours(0, 0, 0, 0)
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
// Сегодня
if (diffDays === 0) {
return 'Сегодня'
}
// Завтра
if (diffDays === 1) {
return 'Завтра'
}
// Вчера
if (diffDays === -1) {
return 'Вчера'
}
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
if (diffDays > 0 && diffDays <= 7) {
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
const dayOfWeek = targetDate.getDay()
return dayNames[dayOfWeek]
}
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
// Если это число из того же года - только день и месяц
if (targetDate.getFullYear() === now.getFullYear()) {
const displayDay = targetDate.getDate()
const displayMonth = monthNames[targetDate.getMonth()]
return `${displayDay} ${displayMonth}`
}
// Для других случаев - полная дата
const displayDay = targetDate.getDate()
const displayMonth = monthNames[targetDate.getMonth()]
const displayYear = targetDate.getFullYear()
return `${displayDay} ${displayMonth} ${displayYear}`
}
// Функция для форматирования числа как %.4g в Go (до 4 значащих цифр)
const formatScore = (num) => {
if (num === 0) return '0'
// Используем toPrecision(4) для получения до 4 значащих цифр
let str = num.toPrecision(4)
// Убираем лишние нули в конце (но оставляем точку если есть цифры после неё)
str = str.replace(/\.?0+$/, '')
// Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно
if (str.includes('e+') || str.includes('e-')) {
const numValue = parseFloat(str)
// Для чисел >= 10000 используем экспоненциальную нотацию
if (Math.abs(numValue) >= 10000) {
return str
}
// Для остальных конвертируем в обычное число
return numValue.toString().replace(/\.?0+$/, '')
}
return str
}
// Функция для формирования сообщения Telegram в реальном времени
const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => {
if (!task) return ''
// Вычисляем score для каждой награды основной задачи
const rewardStrings = {}
const progressionBase = task.progression_base
const hasProgression = progressionBase != null
// Если прогрессия не введена - используем progression_base
const value = progressionValue && progressionValue.trim() !== ''
? parseFloat(progressionValue)
: (hasProgression ? progressionBase : null)
rewards.forEach(reward => {
let score = reward.value
if (reward.use_progression && hasProgression) {
if (value !== null && !isNaN(value)) {
score = (value / progressionBase) * reward.value
} else {
// Если прогрессия не введена, используем progression_base (score = reward.value)
score = reward.value
}
}
const scoreStr = score >= 0
? `**${reward.project_name}+${formatScore(score)}**`
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
rewardStrings[reward.position] = scoreStr
})
// Функция для замены плейсхолдеров
const replacePlaceholders = (message, rewardStrings) => {
let result = message
// Сначала защищаем экранированные плейсхолдеры
const escapedMarkers = {}
for (let i = 0; i < 100; i++) {
const escaped = `\\$${i}`
const marker = `__ESCAPED_DOLLAR_${i}__`
if (result.includes(escaped)) {
escapedMarkers[marker] = escaped
result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
}
}
// Заменяем ${0}, ${1}, и т.д.
for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}`
if (rewardStrings[i]) {
result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i])
}
}
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
for (let i = 99; i >= 0; i--) {
if (rewardStrings[i]) {
const searchStr = `$${i}`
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
result = result.replace(regex, rewardStrings[i])
}
}
// Восстанавливаем экранированные
Object.entries(escapedMarkers).forEach(([marker, escaped]) => {
result = result.replace(new RegExp(marker, 'g'), escaped)
})
return result
}
// Формируем сообщение основной задачи
let mainTaskMessage = task.reward_message && task.reward_message.trim() !== ''
? replacePlaceholders(task.reward_message, rewardStrings)
: task.name
// Формируем сообщения подзадач
const subtaskMessages = []
subtasks.forEach(subtask => {
if (!selectedSubtasks.has(subtask.task.id)) return
if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return
// Вычисляем score для наград подзадачи
const subtaskRewardStrings = {}
subtask.rewards.forEach(reward => {
let score = reward.value
const subtaskProgressionBase = subtask.task.progression_base
if (reward.use_progression) {
if (subtaskProgressionBase != null && value !== null && !isNaN(value)) {
score = (value / subtaskProgressionBase) * reward.value
} else if (hasProgression && value !== null && !isNaN(value)) {
score = (value / progressionBase) * reward.value
} else if (subtaskProgressionBase != null) {
// Если прогрессия не введена, используем progression_base подзадачи (score = reward.value)
score = reward.value
} else if (hasProgression) {
// Если у подзадачи нет progression_base, используем основной (score = reward.value)
score = reward.value
}
}
const scoreStr = score >= 0
? `**${reward.project_name}+${formatScore(score)}**`
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
subtaskRewardStrings[reward.position] = scoreStr
})
const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings)
subtaskMessages.push(subtaskMessage)
})
// Формируем итоговое сообщение
let finalMessage = mainTaskMessage
subtaskMessages.forEach(subtaskMsg => {
finalMessage += '\n + ' + subtaskMsg
})
return finalMessage
}
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) {
const { authFetch } = useAuth()
const [taskDetail, setTaskDetail] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedSubtasks, setSelectedSubtasks] = useState(new Set())
const [progressionValue, setProgressionValue] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [wishlistInfo, setWishlistInfo] = useState(null)
const [isSaving, setIsSaving] = useState(false)
const [completeAtEndOfDay, setCompleteAtEndOfDay] = useState(false)
const fetchTaskDetail = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await authFetch(`${API_URL}/${taskId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки задачи')
}
const data = await response.json()
setTaskDetail(data)
// Используем информацию о wishlist из ответа API
if (data.wishlist_info) {
setWishlistInfo({
id: data.wishlist_info.id,
name: data.wishlist_info.name,
unlocked: data.wishlist_info.unlocked || false
})
} else {
setWishlistInfo(null)
}
// Предзаполнение данных из драфта
if (data.draft_progression_value != null) {
setProgressionValue(data.draft_progression_value.toString())
}
if (data.draft_subtasks && data.draft_subtasks.length > 0) {
// Создаем Set из ID подзадач из драфта
const draftSubtaskIDs = new Set(data.draft_subtasks.map(ds => ds.subtask_id))
// Фильтруем только те подзадачи, которые существуют в текущих подзадачах задачи
const validSubtaskIDs = new Set()
if (data.subtasks) {
data.subtasks.forEach(subtask => {
if (draftSubtaskIDs.has(subtask.task.id)) {
validSubtaskIDs.add(subtask.task.id)
}
})
}
setSelectedSubtasks(validSubtaskIDs)
}
// Значение чекбокса будет установлено в useEffect при изменении taskDetail
} catch (err) {
setError(err.message)
console.error('Error fetching task detail:', err)
} finally {
setLoading(false)
}
}, [taskId, authFetch])
useEffect(() => {
if (taskId) {
fetchTaskDetail()
} else {
// Сбрасываем состояние при закрытии модального окна
setTaskDetail(null)
setLoading(true)
setError(null)
setSelectedSubtasks(new Set())
setProgressionValue('')
setCompleteAtEndOfDay(false)
}
}, [taskId, fetchTaskDetail])
const handleSubtaskToggle = (subtaskId) => {
setSelectedSubtasks(prev => {
const newSet = new Set(prev)
if (newSet.has(subtaskId)) {
newSet.delete(subtaskId)
} else {
newSet.add(subtaskId)
}
return newSet
})
}
const handleSave = async () => {
if (!taskDetail) return
// Если чекбокс включен - выполняем в конце дня, иначе сохраняем без автовыполнения
const autoComplete = completeAtEndOfDay
setIsSaving(true)
try {
const payload = {
auto_complete: autoComplete,
children_task_ids: Array.from(selectedSubtasks)
}
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) {
const parsedValue = parseFloat(progressionValue)
if (isNaN(parsedValue)) {
throw new Error('Неверное значение')
}
payload.progression_value = parsedValue
} else {
// Если прогрессия не введена - используем progression_base
payload.progression_value = taskDetail.task.progression_base
}
} else {
// Если нет progression_base, но пользователь ввел значение - отправляем его
if (progressionValue.trim()) {
const parsedValue = parseFloat(progressionValue)
if (!isNaN(parsedValue)) {
payload.progression_value = parsedValue
}
}
}
const endpoint = autoComplete
? `${API_URL}/${taskId}/complete-at-end-of-day`
: `${API_URL}/${taskId}/draft`
const response = await authFetch(endpoint, {
method: autoComplete ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при сохранении драфта')
}
setToastMessage({
text: autoComplete
? 'Задача будет выполнена в конце дня'
: 'Драфт сохранен',
type: 'success'
})
// Обновляем данные задачи, чтобы получить актуальное значение auto_complete
await fetchTaskDetail()
// Обновляем данные задачи в списке
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно после успешного сохранения
if (onClose) {
onClose()
}
} catch (err) {
console.error('Error saving draft:', err)
setToastMessage({ text: err.message || 'Ошибка при сохранении драфта', type: 'error' })
} finally {
setIsSaving(false)
}
}
const handleComplete = async () => {
if (!taskDetail) return
// Проверяем, что желание разблокировано (если есть связанное желание)
if (wishlistInfo && !wishlistInfo.unlocked) {
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
return
}
setIsCompleting(true)
try {
const payload = {
children_task_ids: Array.from(selectedSubtasks)
}
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) {
payload.value = parseFloat(progressionValue)
if (isNaN(payload.value)) {
throw new Error('Неверное значение')
}
} else {
// Если прогрессия не введена - используем progression_base
payload.value = taskDetail.task.progression_base
}
}
const endpoint = `${API_URL}/${taskId}/complete`
const response = await authFetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
}
// Показываем уведомление о выполнении
if (onTaskCompleted) {
onTaskCompleted()
}
// Обновляем список и закрываем модальное окно
if (onRefresh) {
onRefresh()
}
if (onClose) {
onClose()
}
} catch (err) {
console.error('Error completing task:', err)
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleCompleteFinally = async () => {
if (!taskDetail) return
// Проверяем, что желание разблокировано (если есть связанное желание)
if (wishlistInfo && !wishlistInfo.unlocked) {
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
return
}
setIsCompleting(true)
try {
const payload = {
children_task_ids: Array.from(selectedSubtasks)
}
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) {
payload.value = parseFloat(progressionValue)
if (isNaN(payload.value)) {
throw new Error('Неверное значение')
}
} else {
// Если прогрессия не введена - используем progression_base
payload.value = taskDetail.task.progression_base
}
}
const endpoint = `${API_URL}/${taskId}/complete-and-delete`
const response = await authFetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
}
// Показываем уведомление о выполнении
if (onTaskCompleted) {
onTaskCompleted()
}
// Обновляем список и закрываем модальное окно
if (onRefresh) {
onRefresh()
}
if (onClose) {
onClose()
}
} catch (err) {
console.error('Error completing task:', err)
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
} finally {
setIsCompleting(false)
}
}
if (!taskId) return null
const { task, rewards, subtasks } = taskDetail || {}
const hasProgression = task?.progression_base != null
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
const canComplete = !wishlistInfo || wishlistInfo.unlocked
const hasProgressionOrSubtasks = hasProgression || (subtasks && subtasks.length > 0)
// Определяем, является ли задача одноразовой
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
// Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д.
// Повторяющаяся задача: когда есть значение (не null и не 0)
// Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0)
// Проверяем, что оба поля отсутствуют (null или undefined)
const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) &&
(task?.repetition_date == null || task?.repetition_date === undefined)
// Вычисляем следующую дату для неодноразовых задач
const nextTaskDate = useMemo(() => {
if (!task || isOneTime) return null
const now = new Date()
now.setHours(0, 0, 0, 0)
let nextDate = null
if (task.repetition_date) {
// Для задач с repetition_date - вычисляем следующую подходящую дату
nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
}
if (!nextDate) return null
nextDate.setHours(0, 0, 0, 0)
return formatDateForDisplay(formatDateToLocal(nextDate))
}, [task, isOneTime])
// Формируем сообщение для Telegram в реальном времени
const telegramMessage = useMemo(() => {
if (!taskDetail) return ''
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
// Обновляем значение чекбокса при изменении taskDetail
useEffect(() => {
if (taskDetail && taskDetail.task) {
const autoCompleteValue = Boolean(taskDetail.task.auto_complete)
console.log('useEffect: Updating completeAtEndOfDay from taskDetail:', autoCompleteValue, 'task.auto_complete:', taskDetail.task.auto_complete)
setCompleteAtEndOfDay(autoCompleteValue)
} else {
setCompleteAtEndOfDay(false)
}
}, [taskDetail])
const modalContent = (
<div className="task-detail-modal-overlay" onClick={onClose}>
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-detail-modal-header">
<h2
className="task-detail-title"
onClick={taskDetail ? () => {
// Закрываем модальное окно БЕЗ history.back() (skipHistoryBack = true)
// handleTabChange заменит запись модального окна через replaceState
onClose?.(true)
onNavigate?.('task-form', { taskId: taskId })
} : undefined}
style={{ cursor: taskDetail ? 'pointer' : 'default' }}
>
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? (
<>
{task.name}
<svg
className="task-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</>
) : 'Задача'}
</h2>
<button onClick={onClose} className="task-detail-close-button">
</button>
</div>
<div className="task-detail-modal-content">
{loading && (
<div className="loading">Загрузка...</div>
)}
{error && !loading && (
<LoadingError onRetry={fetchTaskDetail} />
)}
{!loading && !error && taskDetail && (
<>
{/* Информация о связанном желании */}
{task.wishlist_id && wishlistInfo && (
<div className="task-wishlist-link">
<div className="task-wishlist-link-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
<span className="task-wishlist-link-label">Связано с желанием:</span>
<button
onClick={() => {
if (onClose) onClose()
if (onNavigate && wishlistInfo) {
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
}
}}
className="task-wishlist-link-button"
>
{wishlistInfo.name}
</button>
</div>
</div>
)}
{/* Поле ввода прогрессии */}
{hasProgression && (
<div className="progression-section">
<label className="progression-label">Значение прогрессии</label>
<input
type="number"
step="any"
value={progressionValue}
onChange={(e) => setProgressionValue(e.target.value)}
placeholder={task.progression_base?.toString() || ''}
className="progression-input"
/>
</div>
)}
{/* Список подзадач */}
{subtasks && subtasks.length > 0 && (
<div className="task-subtasks">
{subtasks.map((subtask) => {
const subtaskName = subtask.task.name || 'Подзадача'
return (
<div key={subtask.task.id} className="subtask-item">
<label className="subtask-checkbox-label">
<input
type="checkbox"
checked={selectedSubtasks.has(subtask.task.id)}
onChange={() => handleSubtaskToggle(subtask.task.id)}
className="subtask-checkbox"
/>
<div className="subtask-content">
<div className="subtask-name">{subtaskName}</div>
</div>
</label>
</div>
)
})}
</div>
)}
{/* Разделитель - показываем только если есть контент перед ним */}
{(task.wishlist_id || hasProgression || (subtasks && subtasks.length > 0)) && (
<div className="task-detail-divider"></div>
)}
{/* Сообщение награды */}
<div className="telegram-message-preview">
<div className="telegram-message-label">Сообщение награды:</div>
<div className="telegram-message-text" dangerouslySetInnerHTML={{
__html: telegramMessage
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
}} />
</div>
{/* Кнопки действий */}
<div className="task-actions-section">
{/* Чекбокс над кнопками */}
<div className="complete-at-end-of-day-checkbox">
<label className="checkbox-label">
<input
type="checkbox"
checked={completeAtEndOfDay}
onChange={(e) => {
console.log('Checkbox changed to:', e.target.checked)
setCompleteAtEndOfDay(e.target.checked)
}}
disabled={isSaving || !canComplete}
className="checkbox-input"
/>
<span>Выполнить в конце дня</span>
</label>
</div>
<div className="task-actions-buttons">
{/* Левая часть: кнопка "Выполнить" */}
<div className="task-action-left">
<button
onClick={handleComplete}
disabled={isCompleting || !canComplete}
className="action-button action-button-check"
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : 'Выполнить'}
>
Выполнить
</button>
</div>
{/* Правая часть: кнопка "Сохранить" */}
<div className="task-action-complete-buttons">
<button
onClick={handleSave}
disabled={isSaving || !canComplete}
className="action-button action-button-save"
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{/* Дата слева */}
{!isOneTime && nextTaskDate && (
<div className="next-task-date-info">
Следующая: <span className="next-task-date-bold">{nextTaskDate}</span>
</div>
)}
</div>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default TaskDetail

View File

@@ -0,0 +1,610 @@
.task-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.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;
}
.task-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.task-form form {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: all 0.2s;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-group label input[type="checkbox"] {
margin-right: 0.5rem;
}
.progression-button {
padding: 0.5rem;
border: 2px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2.5rem;
height: 2.5rem;
background: transparent;
color: #6b7280;
}
.progression-button-outlined {
background: transparent;
color: #6b7280;
border-color: #d1d5db;
}
.progression-button-filled {
background: #10b981;
color: white;
border-color: #10b981;
}
.progression-button:hover {
background: #f3f4f6;
color: #6b7280;
border-color: #9ca3af;
}
.progression-button-filled:hover {
background: #059669;
border-color: #059669;
}
.progression-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.progression-button-outlined:focus {
background: transparent !important;
color: #6b7280 !important;
border-color: #d1d5db !important;
}
.progression-button-filled:focus {
background: #10b981 !important;
color: white !important;
border-color: #10b981 !important;
}
.progression-button-subtask.progression-button-filled {
background: #10b981;
color: white;
border-color: #10b981;
}
.progression-button-subtask.progression-button-filled:hover {
background: #059669;
border-color: #059669;
}
.progression-button-subtask.progression-button-filled:focus {
background: #10b981 !important;
color: white !important;
border-color: #10b981 !important;
}
.rewards-container {
margin-top: 0.75rem;
}
.reward-item {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.reward-item:last-child {
margin-bottom: 0;
}
.reward-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
background: #f3f4f6;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
}
.subtask-name-input {
margin-bottom: 0.75rem;
}
.reward-item .form-input {
flex: 1;
}
.reward-item .reward-project-input {
flex: 3;
}
.reward-item .reward-score-input {
flex: 1;
}
.subtasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.subtasks-header label {
margin: 0;
display: flex;
align-items: center;
height: 2rem;
line-height: 2rem;
}
.add-subtask-button {
padding: 0.375rem;
background: #6366f1;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2rem;
height: 2rem;
}
.add-subtask-button:hover {
background: #4f46e5;
}
.subtask-form-item {
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
.subtask-header-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.subtask-position-controls {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.move-subtask-button {
padding: 0.25rem;
background: #f3f4f6;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 1.75rem;
flex-shrink: 0;
}
.move-subtask-button:hover:not(:disabled) {
background: #e5e7eb;
border-color: #9ca3af;
color: #374151;
}
.move-subtask-button:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f9fafb;
}
.move-subtask-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.subtask-name-input {
flex: 1;
margin-bottom: 0;
}
.subtask-rewards {
margin-top: 0.75rem;
}
.remove-subtask-button {
padding: 0.5rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 2.5rem;
height: 2.5rem;
}
.remove-subtask-button:hover {
background: #dc2626;
}
.error-message {
color: #ef4444;
margin-bottom: 1rem;
padding: 0.75rem;
background: #fef2f2;
border-radius: 0.375rem;
border: 1px solid #fecaca;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.cancel-button,
.submit-button,
.delete-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: #f3f4f6;
color: #374151;
}
.cancel-button:hover {
background: #e5e7eb;
}
.submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
flex: 1;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-button {
background: #ef4444;
color: white;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
width: 44px;
}
.delete-button:hover:not(:disabled) {
background: #dc2626;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.wishlist-link-info {
padding: 0.75rem;
background-color: #f0f9ff;
border-radius: 6px;
border: 1px solid #bae6fd;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wishlist-link-text {
font-size: 0.9rem;
color: #374151;
}
.wishlist-link-text strong {
color: #6366f1;
font-weight: 600;
}
.wishlist-unlink-x {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #9ca3af;
font-size: 1rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
flex-shrink: 0;
}
.wishlist-unlink-x:hover {
background-color: rgba(239, 68, 68, 0.1);
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;
}
/* Group Autocomplete */
.group-autocomplete {
position: relative;
}
.group-autocomplete-input-wrapper {
position: relative;
}
.group-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.group-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
.group-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.group-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.group-autocomplete-item:last-child {
border-bottom: none;
}
.group-autocomplete-item:hover,
.group-autocomplete-item.highlighted {
background: #f3f4f6;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,876 @@
.task-list {
max-width: 42rem; /* max-w-2xl = 672px */
margin: 0 auto;
padding-bottom: 2.5rem; /* Отступ для фиксированной кнопки добавления */
}
.task-search-container {
position: relative;
margin-bottom: 1.5rem;
}
.task-search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
z-index: 1;
}
.task-search-input {
width: 100%;
padding: 0.75rem 5rem 0.75rem 3rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
background: white;
color: #1f2937;
transition: all 0.2s;
}
.task-search-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.task-search-input::placeholder {
color: #9ca3af;
}
/* Кнопка переключения группировки */
.task-grouping-toggle {
position: absolute;
right: 1rem; /* Такой же отступ, как у иконки лупы */
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6366f1;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
}
.task-grouping-toggle:hover {
background: rgba(99, 102, 241, 0.1);
color: #4f46e5;
}
.task-search-clear {
position: absolute;
right: 0.75rem; /* Остаётся на месте */
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
font-size: 1.125rem;
line-height: 1;
border-radius: 0.25rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.task-search-clear:hover {
background: #f3f4f6;
color: #1f2937;
}
.add-task-button {
width: 100%;
padding: 0.75rem 1rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-bottom: 1.5rem;
transition: all 0.2s;
}
.add-task-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.task-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.task-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
margin: 1rem 0;
}
.task-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.task-item:hover {
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.task-item-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.task-checkmark {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #9ca3af;
transition: all 0.2s;
border-radius: 50%;
padding: 2px;
}
.task-checkmark:hover {
color: #6366f1;
background-color: #f3f4f6;
}
.task-checkmark .checkmark-check {
opacity: 0;
transition: opacity 0.2s;
}
.task-checkmark:hover .checkmark-check {
opacity: 1;
}
.task-checkmark-detail:hover {
color: #8b5cf6;
}
.task-checkmark {
position: relative;
}
.task-checkmark-auto-complete .checkmark-check {
opacity: 0 !important;
}
.task-checkmark-auto-complete-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #9ca3af;
pointer-events: none;
z-index: 1;
}
.task-checkmark-wishlist-lightning-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #9ca3af;
pointer-events: none;
z-index: 1;
}
.task-name-container {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
overflow: hidden;
}
.task-name-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
overflow: hidden;
}
.task-name {
font-size: 1rem;
font-weight: 500;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.task-next-show-date {
font-size: 0.75rem;
color: #6b7280;
font-weight: 400;
}
.task-subtasks-count {
color: #9ca3af;
font-size: 0.875rem;
font-weight: 400;
}
.task-badge-bar {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.25rem;
}
.task-progression-icon {
color: #9ca3af;
flex-shrink: 0;
}
.task-infinite-icon {
color: #9ca3af;
flex-shrink: 0;
}
.task-recurring-icon {
color: #9ca3af;
flex-shrink: 0;
}
.task-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.task-completed-count {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
}
.task-postpone-button {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.task-postpone-button:hover {
background: #f3f4f6;
color: #6366f1;
}
.task-postpone-modal-overlay {
position: fixed !important;
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: 9999 !important;
padding: 1rem;
}
.task-postpone-modal {
background: white;
border-radius: 0.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.task-postpone-modal-header {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.task-postpone-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.task-postpone-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-postpone-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-postpone-modal-content {
padding: 0 1.5rem 1.5rem 1.5rem;
}
.task-postpone-calendar {
margin-bottom: 1rem;
}
/* Стили для react-day-picker v9 */
.task-postpone-calendar .rdp {
--rdp-cell-size: 40px;
--rdp-accent-color: #9ca3af;
--rdp-accent-background-color: #9ca3af;
--rdp-background-color: transparent;
--rdp-outline: none;
--rdp-outline-selected: none;
--rdp-selected-border: none;
--rdp-selected-font: inherit;
margin: 0;
}
/* Ячейка дня */
.task-postpone-calendar .rdp-day {
border-radius: 0.375rem;
transition: all 0.2s;
}
/* Кнопка внутри дня */
.task-postpone-calendar .rdp-day_button {
border-radius: 0.375rem;
transition: all 0.2s;
}
.task-postpone-calendar .rdp-day_button:hover:not([disabled]) {
background-color: #f3f4f6;
}
/* Сегодняшняя дата - жирный текст */
.task-postpone-calendar .rdp-today .rdp-day_button,
.task-postpone-calendar .rdp-day.rdp-today button,
.task-postpone-calendar [data-today="true"] button,
.task-postpone-calendar .rdp [data-today] button {
font-weight: 700 !important;
color: #1f2937;
}
/* Выбранная дата - серый маленький залитый круг */
.task-postpone-calendar .rdp-selected {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.task-postpone-calendar .rdp-selected .rdp-day_button {
border: none !important;
border-radius: 50% !important;
background-color: #e5e7eb !important;
color: #6b7280 !important;
font-weight: 400 !important;
outline: none !important;
box-shadow: none !important;
cursor: pointer;
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
min-height: 28px !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.task-postpone-calendar .rdp-selected .rdp-day_button:hover {
background-color: #d1d5db !important;
}
/* Если дата одновременно сегодняшняя и выбранная */
.task-postpone-calendar .rdp-today.rdp-selected .rdp-day_button {
color: #6b7280 !important;
background-color: #e5e7eb !important;
font-weight: 700 !important;
}
/* Недоступные даты (прошлые) */
.task-postpone-calendar .rdp-disabled .rdp-day_button {
opacity: 1 !important;
cursor: not-allowed;
color: #9ca3af !important;
background-color: transparent !important;
}
.task-postpone-calendar .rdp-disabled .rdp-day_button:hover {
background-color: transparent !important;
}
/* Дни из других месяцев */
.task-postpone-calendar .rdp-outside .rdp-day_button {
opacity: 0.3;
color: #9ca3af;
}
/* Заголовок календаря */
.task-postpone-calendar .rdp-caption_label {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
}
.task-postpone-calendar .rdp-nav_button {
border-radius: 0.375rem;
transition: all 0.2s;
}
.task-postpone-calendar .rdp-nav_button:hover {
background-color: #f3f4f6;
}
/* Адаптивность для мобильных устройств */
@media (max-width: 640px) {
.task-postpone-calendar .rdp {
--rdp-cell-size: 36px;
}
.task-postpone-calendar .rdp-caption_label {
font-size: 0.875rem;
}
}
.task-postpone-quick-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.task-postpone-quick-button {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.task-postpone-quick-button:hover:not(:disabled) {
background: #f3f4f6;
border-color: #6366f1;
color: #6366f1;
}
.task-postpone-quick-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-postpone-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
position: relative;
}
.task-postpone-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.task-postpone-display-date {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
background: white;
cursor: pointer;
transition: all 0.2s;
color: #1f2937;
user-select: none;
}
.task-postpone-display-date:hover {
border-color: #6366f1;
background: #f9fafb;
}
.task-postpone-display-date:active {
background: #f3f4f6;
}
.task-postpone-submit-checkmark {
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
min-width: 3rem;
}
.task-postpone-submit-checkmark:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.task-postpone-submit-checkmark:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-menu-button {
background: none;
border: none;
font-size: 1.25rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-menu-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-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: 1000;
}
.task-modal {
background: white;
border-radius: 0.5rem;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.task-modal-header {
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.task-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.task-modal-actions {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-modal-edit,
.task-modal-delete {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.task-modal-edit {
background: #6366f1;
color: white;
}
.task-modal-edit:hover {
background: #4f46e5;
}
.task-modal-delete {
background: #ef4444;
color: white;
}
.task-modal-delete:hover:not(:disabled) {
background: #dc2626;
}
.task-modal-delete:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading,
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
.loading-details {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.project-group {
margin-bottom: 2rem;
}
.project-group-no-tasks {
margin-bottom: 0.5rem;
}
.project-group-header {
position: sticky;
top: 0;
z-index: 10;
background: #f3f4f6;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
}
.project-group-header-clickable {
cursor: pointer;
padding: 0.5rem;
margin: -0.5rem -0.5rem -0.5rem -0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.2s;
}
.project-group-header-clickable:hover {
background-color: #f9fafb;
}
.project-group-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.project-group-title-empty {
color: #9ca3af;
}
.completed-toggle-header {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.completed-toggle-header:hover {
background: #f3f4f6;
color: #1f2937;
border-color: #d1d5db;
}
.completed-toggle-icon {
font-size: 0.75rem;
transition: transform 0.2s;
}
.completed-toggle-count {
font-size: 0.875rem;
font-weight: 500;
}
.completed-tasks {
margin-top: 0.5rem;
}
.completed-tasks .task-item {
opacity: 0.7;
}
.empty-group {
padding: 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
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: 1000;
}
.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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Integrations.css'
function TelegramIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [integration, setIntegration] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchIntegration()
}, [])
const fetchIntegration = async () => {
try {
setLoading(true)
const response = await authFetch('/api/integrations/telegram')
if (!response.ok) {
throw new Error('Ошибка при загрузке интеграции')
}
const data = await response.json()
setIntegration(data)
} catch (error) {
console.error('Error fetching integration:', error)
setError('Не удалось загрузить данные интеграции')
} finally {
setLoading(false)
}
}
const handleOpenBot = () => {
if (integration?.deep_link) {
window.open(integration.deep_link, '_blank')
}
}
const handleRefresh = () => {
fetchIntegration()
}
if (loading) {
return (
<div className="fixed inset-0 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>
)
}
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="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
{integration?.is_connected ? (
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center text-green-700">
<span className="text-xl mr-2"></span>
<span className="font-medium">Telegram подключен</span>
</div>
</div>
{integration.telegram_user_id && (
<div className="text-sm text-gray-600">
Telegram ID: <span className="font-mono">{integration.telegram_user_id}</span>
</div>
)}
<button
onClick={handleOpenBot}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Открыть бота
</button>
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center text-yellow-700">
<span className="text-xl mr-2"></span>
<span className="font-medium">Telegram не подключен</span>
</div>
<p className="mt-2 text-sm text-gray-600">
Нажмите кнопку ниже и отправьте команду /start в боте
</p>
</div>
<button
onClick={handleOpenBot}
disabled={!integration?.deep_link}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Подключить Telegram
</button>
<button
onClick={handleRefresh}
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Проверить подключение
</button>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">Инструкция</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Telegram"</li>
<li>В открывшемся Telegram нажмите "Start" или отправьте /start</li>
<li>Вернитесь сюда и нажмите "Проверить подключение"</li>
</ol>
</div>
</div>
)
}
export default TelegramIntegration

View File

@@ -0,0 +1,466 @@
.test-container {
min-height: 400px;
}
.test-container-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #f5f5f5;
z-index: 1500;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
.test-duration-selection {
text-align: center;
}
.test-duration-selection h2 {
color: #2c3e50;
margin-bottom: 2rem;
}
.test-error {
background-color: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.duration-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
}
.duration-button {
background-color: #3498db;
color: white;
border: none;
padding: 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
text-align: center;
}
.duration-button:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-2px);
}
.duration-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.duration-label {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.duration-count {
font-size: 0.9rem;
opacity: 0.9;
}
.test-loading {
margin-top: 2rem;
text-align: center;
color: #666;
}
.test-screen {
min-height: 100vh;
padding-top: 3rem;
}
.test-progress {
margin-top: 1rem;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.test-progress .progress-text {
font-size: 1.5rem;
color: #2c3e50;
font-weight: 500;
}
.test-card-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
perspective: 1000px;
}
.test-card {
width: 100%;
max-width: 400px;
height: 500px;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s;
cursor: pointer;
}
.test-card.flipped {
transform: rotateY(180deg);
}
.test-card-front,
.test-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
.test-card-front {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.test-card-back {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
transform: rotateY(180deg);
}
.test-card-content {
padding: 2rem;
text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.test-word {
font-size: 3rem;
font-weight: bold;
margin-bottom: 0;
}
.test-translation {
font-size: 2.5rem;
margin-bottom: 0;
}
.test-card-hint {
font-size: 0.9rem;
opacity: 0.8;
margin-top: 2rem;
}
.test-card-actions {
display: flex;
gap: 1rem;
width: 100%;
justify-content: center;
margin-top: auto;
padding-top: 2rem;
position: absolute;
bottom: 2rem;
left: 0;
right: 0;
}
.test-action-button {
padding: 1.25rem 2.5rem;
border: none;
border-radius: 8px;
font-size: 1.25rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
min-width: 160px;
}
.test-action-button:hover {
transform: scale(1.05);
}
.test-action-button:active {
transform: scale(0.95);
}
.success-button {
background-color: #27ae60;
color: white;
}
.success-button:hover {
background-color: #229954;
}
.failure-button {
background-color: #e74c3c;
color: white;
}
.failure-button:hover {
background-color: #c0392b;
}
.test-results {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
.results-stats {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
align-content: start;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;
box-sizing: border-box;
width: 100%;
min-height: 0;
}
.result-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
}
.result-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.result-item:last-child {
margin-bottom: 2rem;
}
.result-word-content {
flex: 1;
}
.result-word-header {
margin-bottom: 0.75rem;
}
.result-word {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.result-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.result-stats {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.result-stat-success {
color: #3498db;
}
.result-stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.result-stat-failure {
color: #e74c3c;
}
.test-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
.preview-stats {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
align-content: start;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;
box-sizing: border-box;
width: 100%;
min-height: 0;
}
.preview-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
align-self: start;
}
.preview-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.preview-word-content {
flex: 1;
}
.preview-word-header {
margin-bottom: 0.75rem;
}
.preview-word {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.preview-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.preview-stats-numbers {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.preview-stat-success {
color: #3498db;
}
.preview-stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.preview-stat-failure {
color: #e74c3c;
}
.preview-actions {
display: flex;
justify-content: center;
padding: 1rem;
flex-shrink: 0;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
position: relative;
z-index: 10;
}
.test-start-button {
padding: 1.25rem 3rem;
background-color: #27ae60;
color: white;
border: none;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, background-color 0.2s;
min-width: 200px;
}
.test-start-button:hover {
background-color: #229954;
transform: scale(1.05);
}
.test-start-button:active {
transform: scale(0.95);
}
.results-actions {
display: flex;
justify-content: center;
padding: 1rem;
flex-shrink: 0;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
position: relative;
z-index: 10;
}
.test-finish-button {
padding: 1.25rem 3rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, background-color 0.2s;
min-width: 200px;
}
.test-finish-button:hover {
background-color: #2980b9;
transform: scale(1.05);
}
.test-finish-button:active {
transform: scale(0.95);
}

View File

@@ -0,0 +1,772 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './TestWords.css'
import './Integrations.css'
const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10
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([]) // Пул слов для показа
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
const [flippedCards, setFlippedCards] = useState(new Set())
const [wordStats, setWordStats] = useState({}) // Локальная статистика
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
const isFinishingRef = useRef(false)
const wordStatsRef = useRef({})
const processingRef = useRef(false)
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => {
if (currentPool.length === 0 || allWords.length === 0) {
return currentPool
}
// Подсчитываем, сколько раз каждое слово встречается в текущем пуле
const wordCounts = {}
currentPool.forEach(word => {
wordCounts[word.id] = (wordCounts[word.id] || 0) + 1
})
// Получаем список уникальных слов, которые есть в пуле
const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id))
const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id))
if (uniqueWords.length === 0) {
return currentPool
}
// Проверяем, есть ли в пуле слова, отличные от исключаемого
const hasOtherWords = uniqueWords.some(w => w.id !== excludeFirstWordId)
const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null
// Создаём массив всех экземпляров слов для распределения
const allInstances = []
for (const word of uniqueWords) {
const count = wordCounts[word.id]
for (let i = 0; i < count; i++) {
allInstances.push({ ...word })
}
}
// Перемешиваем экземпляры
for (let i = allInstances.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[allInstances[i], allInstances[j]] = [allInstances[j], allInstances[i]]
}
// Используем жадный алгоритм: на каждую позицию выбираем слово,
// которое максимально далеко от своего последнего появления
const totalSlots = currentPool.length
const newPool = new Array(totalSlots).fill(null)
const lastPosition = {} // Последняя позиция каждого слова
for (let pos = 0; pos < totalSlots; pos++) {
let bestWord = null
let bestWordIndex = -1
let bestDistance = -1
for (let i = 0; i < allInstances.length; i++) {
const word = allInstances[i]
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
if (pos === 0 && word.id === effectiveExcludeId) {
// Проверяем, есть ли другие слова
const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId)
if (hasAlternative) {
continue
}
}
// Вычисляем расстояние от последнего появления этого слова
const lastPos = lastPosition[word.id]
const distance = lastPos === undefined ? totalSlots : (pos - lastPos)
// Выбираем слово с максимальным расстоянием
if (distance > bestDistance) {
bestDistance = distance
bestWord = word
bestWordIndex = i
}
}
if (bestWord !== null) {
newPool[pos] = bestWord
lastPosition[bestWord.id] = pos
allInstances.splice(bestWordIndex, 1)
}
}
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с ближайшим другим
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
for (let i = 1; i < newPool.length; i++) {
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
break
}
}
}
// Пост-обработка: исправляем последовательные дубликаты (одинаковые слова подряд)
let iterations = 0
const maxIterations = totalSlots * 2 // Предотвращаем бесконечный цикл
let hasConsecutiveDuplicates = true
while (hasConsecutiveDuplicates && iterations < maxIterations) {
hasConsecutiveDuplicates = false
iterations++
for (let i = 0; i < newPool.length - 1; i++) {
if (newPool[i] && newPool[i + 1] && newPool[i].id === newPool[i + 1].id) {
// Нашли последовательные дубликаты на позициях i и i+1
// Ищем слово для обмена (не то же самое и не соседнее с дубликатом после обмена)
let swapped = false
for (let j = i + 2; j < newPool.length && !swapped; j++) {
if (!newPool[j]) continue
// Проверяем, что слово на позиции j отличается от дубликата
if (newPool[j].id === newPool[i].id) continue
// Проверяем, что после обмена не создадим новые дубликаты
// Позиция j-1 (если существует) не должна иметь тот же id, что и newPool[i+1]
// Позиция j+1 (если существует) не должна иметь тот же id, что и newPool[i+1]
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
// Меняем местами
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
swapped = true
hasConsecutiveDuplicates = true // Нужна ещё одна итерация для проверки
}
}
// Если не нашли подходящую позицию справа, ищем слева
if (!swapped) {
for (let j = 0; j < i && !swapped; j++) {
if (!newPool[j]) continue
if (newPool[j].id === newPool[i].id) continue
// Для позиции 0: не меняем на исключаемое слово
if (j === 0 && newPool[i + 1].id === effectiveExcludeId) continue
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
swapped = true
hasConsecutiveDuplicates = true
}
}
}
}
}
}
// Ещё раз проверяем позицию 0 после всех обменов
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
for (let i = 1; i < newPool.length; i++) {
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
// Проверяем, не создаст ли обмен дубликат на позиции 1
if (i === 1 || (newPool[1] && newPool[1].id !== newPool[i].id)) {
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
break
}
}
}
}
// Заполняем null-позиции (не должно происходить, но на всякий случай)
for (let i = 0; i < newPool.length; i++) {
if (newPool[i] === null && currentPool[i]) {
newPool[i] = currentPool[i]
}
}
return newPool
}
// Загрузка слов при монтировании
useEffect(() => {
setWords([])
setTestWords([])
setCurrentWord(null)
setFlippedCards(new Set())
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
setTotalAnswers(0)
setError('')
setShowPreview(false) // Сбрасываем экран предпросмотра
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
isFinishingRef.current = false
processingRef.current = false
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)
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
// n = max(1, floor(0.7 * maxCards / количество_слов))
const wordsCount = data.length
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
// Создаем пул, где каждое слово повторяется n раз
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()
}, [wordCount, configId])
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
const getRightCounter = () => {
const total = testWords.length + cardsShown
if (maxCards !== null && maxCards > 0) {
return Math.min(total, maxCards)
}
return total
}
const handleCardFlip = (wordId) => {
setFlippedCards(prev => {
const newSet = new Set(prev)
if (newSet.has(wordId)) {
newSet.delete(wordId)
} else {
newSet.add(wordId)
}
return newSet
})
}
// Завершение теста
const finishTest = async () => {
if (isFinishingRef.current) return
isFinishingRef.current = true
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
setShowResults(true)
// Отправляем статистику на бэкенд
try {
// Получаем актуальные данные из состояния
const currentStats = wordStatsRef.current
// Отправляем все слова, которые были в тесте, с их текущими значениями
// Бэкенд сам обновит только измененные поля
const updates = words.map(word => {
const stats = currentStats[word.id] || {
success: word.success || 0,
failure: word.failure || 0,
lastSuccessAt: word.last_success_at || null,
lastFailureAt: word.last_failure_at || null
}
return {
id: word.id,
success: stats.success || 0,
failure: stats.failure || 0,
last_success_at: stats.lastSuccessAt || null,
last_failure_at: stats.lastFailureAt || null
}
})
if (updates.length === 0) {
console.log('No words to send - empty test')
return
}
const requestBody = { words: updates }
if (configId !== null) {
requestBody.config_id = configId
}
console.log('Sending test progress to backend:', {
wordsCount: updates.length,
configId: configId,
requestBody
})
const response = await authFetch(`${API_URL}/test/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
}
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)
// Можно показать уведомление пользователю, но не блокируем показ результатов
}
}
// Берём карточку из пула (getAndDelete) и показываем её
const showNextCard = () => {
// Проверяем, не завершился ли тест
if (isFinishingRef.current) {
return
}
// Используем функциональное обновление для получения актуального состояния пула
setTestWords(prevPool => {
// Повторная проверка внутри callback (на случай если состояние изменилось)
if (isFinishingRef.current) {
return prevPool
}
// Используем ref для синхронного доступа к счётчику
const nextCardsShown = cardsShownRef.current + 1
// Условие 1: Достигли максимума карточек
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return prevPool
}
// Условие 2: Пул слов пуст
if (prevPool.length === 0) {
finishTest()
return prevPool
}
// getAndDelete: берём слово из пула и удаляем его
const nextWord = prevPool[0]
// Условие 3: Первое слово в пуле null/undefined (не должно происходить, но на всякий случай)
if (!nextWord) {
// Ищем первое не-null слово в пуле
const validWordIndex = prevPool.findIndex(w => w !== null && w !== undefined)
if (validWordIndex === -1) {
// Нет валидных слов - завершаем тест
finishTest()
return prevPool
}
// Берём валидное слово
const validWord = prevPool[validWordIndex]
const updatedPool = [...prevPool.slice(0, validWordIndex), ...prevPool.slice(validWordIndex + 1)]
// Синхронно обновляем ref
cardsShownRef.current = nextCardsShown
setCurrentWord(validWord)
setCardsShown(nextCardsShown)
setFlippedCards(new Set())
return updatedPool
}
const updatedPool = prevPool.slice(1)
// Синхронно обновляем ref ПЕРЕД установкой state
cardsShownRef.current = nextCardsShown
// showCard: показываем карточку
setCurrentWord(nextWord)
setCardsShown(nextCardsShown)
setFlippedCards(new Set())
return updatedPool
})
}
const handleSuccess = (wordId) => {
if (processingRef.current || isFinishingRef.current || showResults) return
processingRef.current = true
const word = words.find(w => w.id === wordId)
if (!word) {
processingRef.current = false
return
}
const now = new Date().toISOString()
// Обновляем статистику: success + 1, lastSuccessAt = now
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
const updatedStats = {
...wordStatsRef.current,
[wordId]: {
success: (currentWordStats.success || 0) + 1,
failure: currentWordStats.failure || 0,
lastSuccessAt: now,
lastFailureAt: currentWordStats.lastFailureAt || null
}
}
wordStatsRef.current = updatedStats
setWordStats(updatedStats)
// Увеличиваем счётчик ответов
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// onSuccess: просто повторяем (showNextCard)
// Карточка уже удалена из пула при показе, просто показываем следующую
showNextCard()
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleFailure = (wordId) => {
if (processingRef.current || isFinishingRef.current || showResults) return
processingRef.current = true
const word = words.find(w => w.id === wordId)
if (!word) {
processingRef.current = false
return
}
const now = new Date().toISOString()
// Обновляем статистику: failure + 1, lastFailureAt = now
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
const updatedStats = {
...wordStatsRef.current,
[wordId]: {
success: currentWordStats.success || 0,
failure: (currentWordStats.failure || 0) + 1,
lastSuccessAt: currentWordStats.lastSuccessAt || null,
lastFailureAt: now
}
}
wordStatsRef.current = updatedStats
setWordStats(updatedStats)
// Увеличиваем счётчик ответов
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// onFailure: возвращаем карточку в пул, сортируем, повторяем
setTestWords(prevPool => {
// cards.add(currentCard): возвращаем слово обратно в пул
let newTestWords = [...prevPool, word]
// cards.sort(): равномерно перераспределяем слова в пуле
// Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа)
newTestWords = redistributeWordsEvenly(newTestWords, words, wordId)
return newTestWords
})
// repeat(): показываем следующую карточку
showNextCard()
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleClose = () => {
window.history.back()
}
const handleStartTest = () => {
setShowPreview(false)
// Показываем первую карточку (берём из пула)
showNextCard()
}
const handleFinish = () => {
onNavigate?.('tasks')
}
const getRandomSide = (word) => {
return word.id % 2 === 0 ? 'word' : 'translation'
}
return (
<div className="test-container test-container-fullscreen">
<button className="close-x-button" onClick={handleClose}>
</button>
{showPreview ? (
<div className="test-preview">
<div className="preview-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="preview-item">
<div className="preview-word-content">
<div className="preview-word-header">
<h3 className="preview-word">{word.name}</h3>
</div>
<div className="preview-translation">{word.translation}</div>
</div>
<div className="preview-stats-numbers">
<span className="preview-stat-success">{stats.success}</span>
<span className="preview-stat-separator"> | </span>
<span className="preview-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="preview-actions">
<button className="test-start-button" onClick={handleStartTest}>
Начать
</button>
</div>
</div>
) : showResults ? (
<div className="test-results">
<div className="results-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="result-item">
<div className="result-word-content">
<div className="result-word-header">
<h3 className="result-word">{word.name}</h3>
</div>
<div className="result-translation">{word.translation}</div>
</div>
<div className="result-stats">
<span className="result-stat-success">{stats.success}</span>
<span className="result-stat-separator"> | </span>
<span className="result-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="results-actions">
<button className="test-finish-button" onClick={handleFinish}>
Закончить
</button>
</div>
</div>
) : (
<div className="test-screen">
{loading && (
<div className="fixed inset-0 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>
)}
{error && (
<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
const isFlipped = flippedCards.has(word.id)
const showSide = getRandomSide(word)
return (
<div className="test-card-container" key={word.id}>
<div
className={`test-card ${isFlipped ? 'flipped' : ''}`}
onClick={() => handleCardFlip(word.id)}
>
<div className="test-card-front">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-word">{word.name}</div>
) : (
<div className="test-translation">{word.translation}</div>
)}
</div>
</div>
<div className="test-card-back">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-translation">{word.translation}</div>
) : (
<div className="test-word">{word.name}</div>
)}
<div className="test-card-actions">
<button
className="test-action-button success-button"
onClick={(e) => {
e.stopPropagation()
handleSuccess(word.id)
}}
>
Знаю
</button>
<button
className="test-action-button failure-button"
onClick={(e) => {
e.stopPropagation()
handleFailure(word.id)
}}
>
Не знаю
</button>
</div>
</div>
</div>
</div>
</div>
)
})()}
{!loading && !error && (
<div className="test-progress">
<div className="progress-text">
{cardsShown} / {getRightCounter()}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default TestWords

View File

@@ -0,0 +1,47 @@
.toast {
position: fixed;
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%) translateY(100px);
z-index: 1000;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
padding: 1rem 1.5rem;
min-width: 250px;
max-width: 400px;
opacity: 0;
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;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast-message {
color: #1f2937;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
}
.toast-error .toast-message {
color: #991b1b;
}

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react'
import './Toast.css'
function Toast({ message, onClose, duration = 3000, type = 'success' }) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(() => {
onClose?.()
}, 300) // Ждем завершения анимации
}, duration)
return () => clearTimeout(timer)
}, [duration, onClose])
if (!isVisible) return null
return (
<div className={`toast toast-${type} ${isVisible ? 'toast-visible' : ''}`}>
<div className="toast-content">
<span className="toast-message">{message}</span>
</div>
</div>
)
}
export default Toast

View File

@@ -0,0 +1,270 @@
import React, { useState } from 'react'
import LoadingError from './LoadingError'
import { useAuth } from './auth/AuthContext'
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
const formatScore = (num) => {
if (num === 0) return '0'
let str = num.toPrecision(4)
str = str.replace(/\.?0+$/, '')
if (str.includes('e+') || str.includes('e-')) {
const numValue = parseFloat(str)
if (Math.abs(numValue) >= 10000) {
return str
}
return numValue.toString().replace(/\.?0+$/, '')
}
return str
}
// Функция для форматирования текста с заменой плейсхолдеров на nodes
const formatEntryText = (text, nodes) => {
if (!text || !nodes || nodes.length === 0) {
return text
}
// Создаем map для быстрого доступа к nodes по индексу
const nodesMap = {}
nodes.forEach(node => {
nodesMap[node.index] = node
})
// Создаем массив для хранения частей текста и React элементов
const parts = []
let lastIndex = 0
let currentText = text
// Сначала защищаем экранированные плейсхолдеры
const escapedMarkers = {}
for (let i = 0; i < 100; i++) {
const escaped = `\\$${i}`
const marker = `__ESCAPED_DOLLAR_${i}__`
if (currentText.includes(escaped)) {
escapedMarkers[marker] = escaped
currentText = currentText.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
}
}
// Заменяем ${0}, ${1}, и т.д.
for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}`
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
if (nodesMap[i] && currentText.includes(placeholder)) {
const node = nodesMap[i]
const scoreStr = node.score >= 0
? `${node.project_name}+${formatScore(node.score)}`
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
currentText = currentText.replace(regex, `__NODE_${i}__`)
// Сохраняем информацию о замене
if (!escapedMarkers[`__NODE_${i}__`]) {
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
}
}
}
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
for (let i = 99; i >= 0; i--) {
if (nodesMap[i]) {
const node = nodesMap[i]
const scoreStr = node.score >= 0
? `${node.project_name}+${formatScore(node.score)}`
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
if (currentText.match(regex)) {
currentText = currentText.replace(regex, `__NODE_${i}__`)
if (!escapedMarkers[`__NODE_${i}__`]) {
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
}
}
}
}
// Разбиваем текст на части и создаем React элементы
const result = []
let searchIndex = 0
while (searchIndex < currentText.length) {
// Ищем следующий маркер
let foundMarker = null
let markerIndex = currentText.length
// Ищем все маркеры
for (const marker in escapedMarkers) {
const index = currentText.indexOf(marker, searchIndex)
if (index !== -1 && index < markerIndex) {
markerIndex = index
foundMarker = marker
}
}
// Если нашли маркер
if (foundMarker) {
// Добавляем текст до маркера
if (markerIndex > searchIndex) {
result.push(currentText.substring(searchIndex, markerIndex))
}
// Добавляем элемент для маркера
const markerData = escapedMarkers[foundMarker]
if (markerData && markerData.type === 'node') {
result.push(
<strong key={`node-${searchIndex}`}>{markerData.text}</strong>
)
} else if (typeof markerData === 'string') {
// Это экранированный плейсхолдер
result.push(markerData)
}
searchIndex = markerIndex + foundMarker.length
} else {
// Больше маркеров нет, добавляем оставшийся текст
if (searchIndex < currentText.length) {
result.push(currentText.substring(searchIndex))
}
break
}
}
return result.length > 0 ? result : currentText
}
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
const { authFetch } = useAuth()
const [deletingIds, setDeletingIds] = useState(new Set())
const handleDelete = async (entryId) => {
if (deletingIds.has(entryId)) return
if (!window.confirm('Вы уверены, что хотите удалить эту запись? Это действие нельзя отменить.')) {
return
}
setDeletingIds(prev => new Set(prev).add(entryId))
try {
const response = await authFetch(`/api/entries/${entryId}`, {
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}`)
}
// Вызываем callback для обновления данных
if (onDelete) {
onDelete()
}
} catch (err) {
console.error('Delete failed:', err)
alert(err.message || 'Не удалось удалить запись')
} finally {
setDeletingIds(prev => {
const next = new Set(prev)
next.delete(entryId)
return next
})
}
}
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
)
}
if (error) {
return <LoadingError onRetry={onRetry} />
}
if (!data || data.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
Нет записей за выбранный день
</div>
)
}
return (
<div className="mt-2 mb-6">
<div className="space-y-3">
{data.map((entry) => (
<div
key={entry.id}
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative group"
>
<button
onClick={() => handleDelete(entry.id)}
disabled={deletingIds.has(entry.id)}
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
color: '#6b7280',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
transition: 'all 0.2s',
opacity: deletingIds.has(entry.id) ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => {
if (!deletingIds.has(entry.id)) {
e.currentTarget.style.backgroundColor = '#f3f4f6'
e.currentTarget.style.color = '#1f2937'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#6b7280'
}}
title="Удалить запись"
>
{deletingIds.has(entry.id) ? (
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
<div className="text-gray-800 whitespace-pre-wrap pr-8">
{formatEntryText(entry.text, entry.nodes)}
</div>
{entry.created_date && (
<div className="text-xs text-gray-500 mt-2">
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
)}
</div>
))}
</div>
</div>
)
}
export default TodayEntriesList

View File

@@ -0,0 +1,210 @@
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 }) {
const { authFetch } = useAuth()
const [connected, setConnected] = useState(false)
const [todoistEmail, setTodoistEmail] = useState('')
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()
// Проверяем URL параметры для сообщений
const params = new URLSearchParams(window.location.search)
const integration = params.get('integration')
const status = params.get('status')
if (integration === 'todoist') {
if (status === 'connected') {
setMessage('✅ Todoist успешно подключен!')
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname)
} else if (status === 'error') {
const errorMsg = params.get('message') || 'Произошла ошибка'
setToastMessage({ text: errorMsg, type: 'error' })
window.history.replaceState({}, '', window.location.pathname)
}
}
}, [])
const checkStatus = async () => {
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/todoist/status')
if (!response.ok) {
throw new Error('Ошибка при проверке статуса')
}
const data = await response.json()
setConnected(data.connected || false)
if (data.connected && data.todoist_email) {
setTodoistEmail(data.todoist_email)
}
} catch (error) {
console.error('Error checking status:', error)
setError(error.message || 'Не удалось проверить статус')
setIsLoadingError(true)
} finally {
setLoading(false)
}
}
const handleConnect = async () => {
try {
setLoading(true)
setError('')
// Получаем URL для редиректа через авторизованный запрос
const response = await authFetch('/api/integrations/todoist/oauth/connect')
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при подключении Todoist')
}
const data = await response.json()
if (data.auth_url) {
// Делаем редирект на Todoist OAuth
window.location.href = data.auth_url
} else {
throw new Error('URL для авторизации не получен')
}
} catch (error) {
console.error('Error connecting Todoist:', error)
setToastMessage({ text: error.message || 'Не удалось подключить Todoist', type: 'error' })
setLoading(false)
}
}
const handleDisconnect = async () => {
if (!window.confirm('Вы уверены, что хотите отключить Todoist?')) {
return
}
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/todoist/disconnect', {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при отключении')
}
setConnected(false)
setTodoistEmail('')
setToastMessage({ text: 'Todoist отключен', type: 'success' })
} catch (error) {
console.error('Error disconnecting:', error)
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="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Todoist интеграция</h1>
{loading ? (
<div className="fixed inset-0 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>
) : connected ? (
<div>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-green-600 font-semibold"> Todoist подключен</span>
</div>
{todoistEmail && (
<div>
<span className="text-gray-600">Email: </span>
<span className="font-medium">{todoistEmail}</span>
</div>
)}
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Как это работает
</h3>
<p className="text-gray-700 mb-2">
Todoist подключен! Закрывайте задачи в Todoist они автоматически
появятся в Play Life.
</p>
<p className="text-gray-600 text-sm">
Никаких дополнительных настроек не требуется. Просто закрывайте задачи
в Todoist, и они будут обработаны автоматически.
</p>
</div>
<button
onClick={handleDisconnect}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Отключить Todoist
</button>
</div>
) : (
<div>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Подключение Todoist</h2>
<p className="text-gray-700 mb-4">
Подключите свой Todoist аккаунт для автоматической обработки закрытых задач.
</p>
<button
onClick={handleConnect}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
Подключить Todoist
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Что нужно сделать
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Todoist"</li>
<li>Авторизуйтесь в Todoist</li>
<li>Готово! Закрытые задачи будут автоматически обрабатываться</li>
</ol>
</div>
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default TodoistIntegration

View File

@@ -0,0 +1,436 @@
/* ===== Tracking Screen ===== */
.tracking-screen {
padding: 0 1rem 1rem 1rem;
}
/* Header with title and close button */
.tracking-header {
position: relative;
margin-bottom: 1.5rem;
padding-top: 1.25rem;
}
.tracking-header h2 {
margin-top: 0;
margin-bottom: 0;
}
.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;
}
/* Week controls: scrollable chips + access button */
.week-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.week-chips-scroll {
display: flex;
flex-wrap: nowrap;
gap: 0.625rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
flex: 1;
min-width: 0;
}
.week-chips-scroll::-webkit-scrollbar {
display: none;
}
.access-icon-btn {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
border: 1px solid #d1d5db;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
}
.access-icon-btn:hover {
border-color: #a5b4fc;
color: #4f46e5;
background: #f5f3ff;
}
/* Week chips */
.week-chips {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
margin-bottom: 1.5rem;
}
.week-chip {
height: 2.25rem;
padding: 0 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
background: transparent;
color: #374151;
border: 1px solid #d1d5db;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.week-chip:hover {
border-color: #9ca3af;
}
.week-chip.selected {
background: white;
color: #111827;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
border-color: #e5e7eb;
}
.week-chip.current:not(.selected) {
border-color: #a5b4fc;
box-shadow: 0 0 0 2px rgba(165, 180, 252, 0.3);
}
/* User tracking cards */
.users-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.user-tracking-card {
background: white;
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.user-tracking-card.current-user {
border: 1px solid #a5b4fc;
background: white;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #d1d5db;
}
.user-name {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.user-total {
font-size: 1.5rem;
font-weight: 700;
}
.percent-green {
color: #10b981;
}
.percent-blue {
color: #3b82f6;
}
.projects-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-row {
display: flex;
justify-content: space-between;
font-size: 1rem;
}
.project-name {
color: #111827;
}
.project-score {
font-weight: 500;
}
/* Access link section */
.access-link-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.access-link-button {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
color: #374151;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.access-link-button:hover {
border-color: #a5b4fc;
color: #4f46e5;
}
/* ===== Tracking Access Screen ===== */
.tracking-access-screen {
padding: 0 1rem 1rem 1rem;
}
.screen-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.access-section {
background: white;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid #e5e7eb;
}
.access-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.section-hint {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.75rem;
}
.create-invite-btn {
width: 100%;
padding: 0.75rem 1rem;
background: linear-gradient(to right, #4f46e5, #7c3aed);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.create-invite-btn:hover:not(:disabled) {
opacity: 0.9;
}
.create-invite-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.create-invite-btn.copied {
background: #10b981;
}
.empty-list {
color: #9ca3af;
font-size: 0.875rem;
padding: 0.5rem 0;
}
.access-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f3f4f6;
}
.access-item:last-child {
border-bottom: none;
}
.access-item-name {
font-weight: 500;
}
.remove-btn {
padding: 0.5rem;
color: #9ca3af;
background: none;
border: none;
cursor: pointer;
border-radius: 0.375rem;
transition: all 0.2s;
}
.remove-btn:hover {
color: #ef4444;
background: #fef2f2;
}
/* ===== Tracking Invite Screen ===== */
.tracking-invite-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.invite-card {
background: white;
border-radius: 1rem;
padding: 2rem;
max-width: 24rem;
width: 100%;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
.invite-card.error-card {
border: 1px solid #fecaca;
}
.invite-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.invite-card h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.invite-info {
margin-bottom: 1.5rem;
}
.user-name-large {
font-size: 1.125rem;
font-weight: 600;
color: #4f46e5;
margin-top: 0.5rem;
}
.accept-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(to right, #4f46e5, #7c3aed);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
margin-bottom: 1rem;
}
.accept-btn:disabled {
opacity: 0.7;
}
.cancel-link {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 0.875rem;
}
.cancel-link:hover {
color: #374151;
}
.error-text {
color: #ef4444;
margin-bottom: 1rem;
}
.error-inline {
color: #ef4444;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.secondary-btn {
padding: 0.75rem 1.5rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
/* Loading */
.loading-container {
text-align: center;
}
.spinner {
width: 3rem;
height: 3rem;
border: 4px solid #e0e7ff;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error-message {
text-align: center;
padding: 2rem;
color: #ef4444;
}

View File

@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './Tracking.css'
// Функция для вычисления номера недели ISO
function getISOWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
}
// Функция для вычисления 5 недель (текущая + 4 предыдущие)
function getLastFiveWeeks() {
const weeks = []
const now = new Date()
for (let i = 0; i < 5; i++) {
const date = new Date(now)
date.setDate(date.getDate() - i * 7)
const week = getISOWeek(date)
const year = date.getFullYear()
weeks.push({
year,
week,
isCurrent: i === 0
})
}
return weeks.reverse() // От старой к новой
}
function Tracking({ onNavigate, activeTab }) {
const { authFetch } = useAuth()
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
const [selectedWeek, setSelectedWeek] = useState(() => {
const initialWeeks = getLastFiveWeeks()
return initialWeeks[initialWeeks.length - 1] // Текущая неделя
})
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const scrollContainerRef = useRef(null)
const currentWeekChipRef = useRef(null)
const prevActiveTabRef = useRef(null)
// Обновление списка недель и сброс выбранной недели на текущую при открытии экрана
useEffect(() => {
// Проверяем, что экран только что открылся (activeTab стал 'tracking')
if (activeTab === 'tracking' && prevActiveTabRef.current !== 'tracking') {
// Пересчитываем недели для получения актуального списка
const updatedWeeks = getLastFiveWeeks()
setWeeks(updatedWeeks)
// Устанавливаем текущую неделю (последняя в списке)
const currentWeek = updatedWeeks[updatedWeeks.length - 1]
setSelectedWeek(currentWeek)
}
prevActiveTabRef.current = activeTab
}, [activeTab])
// Скролл к чипсу текущей недели при открытии экрана
useEffect(() => {
// Выполняем скролл только когда экран открыт и только что открылся
if (activeTab === 'tracking' && currentWeekChipRef.current && scrollContainerRef.current) {
const chip = currentWeekChipRef.current
const container = scrollContainerRef.current
// Небольшая задержка для гарантии рендеринга
setTimeout(() => {
const chipLeft = chip.offsetLeft
const chipWidth = chip.offsetWidth
const containerWidth = container.offsetWidth
const scrollLeft = chipLeft - (containerWidth / 2) + (chipWidth / 2)
container.scrollTo({
left: scrollLeft,
behavior: 'smooth'
})
}, 100)
}
}, [activeTab])
// Функция для обновления данных
const refreshData = async () => {
setLoading(true)
setError(null)
try {
const res = await authFetch(`/api/tracking/stats?year=${selectedWeek.year}&week=${selectedWeek.week}`)
if (res.ok) {
setData(await res.json())
} else {
setError('Ошибка загрузки')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
// Загрузка данных при смене недели
useEffect(() => {
refreshData()
}, [selectedWeek, authFetch])
return (
<div className="tracking-screen max-w-2xl mx-auto">
{/* Заголовок с крестиком */}
<div className="tracking-header">
<h2 className="text-2xl font-semibold text-gray-800">Отслеживание</h2>
</div>
<button className="close-x-button" onClick={() => window.history.back()}></button>
{/* Чипсы недель с кнопкой доступов */}
<div className="week-controls">
<div className="week-chips-scroll" ref={scrollContainerRef}>
{weeks.map(w => (
<button
key={`${w.year}-${w.week}`}
ref={w.isCurrent ? currentWeekChipRef : null}
onClick={() => setSelectedWeek(w)}
className={`week-chip
${selectedWeek.year === w.year && selectedWeek.week === w.week ? 'selected' : ''}
${w.isCurrent ? 'current' : ''}`}
>
Неделя {w.week}
</button>
))}
</div>
<button
className="access-icon-btn"
onClick={() => onNavigate('tracking-access')}
title="Управление доступами"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
</div>
{/* Контент */}
{loading ? (
<div className="loading-spinner">Загрузка...</div>
) : error ? (
<div className="error-message">{error}</div>
) : (
<div className="users-list">
{data?.users.map(user => (
<UserTrackingCard key={user.user_id} user={user} />
))}
</div>
)}
</div>
)
}
// Карточка пользователя с прогрессом
function UserTrackingCard({ user }) {
// Сортируем проекты по priority (1, 2, остальные)
const sortedProjects = [...user.projects].sort((a, b) => {
const pa = a.priority ?? 99
const pb = b.priority ?? 99
return pa - pb
})
const totalPercent = user.total || 0
const getPercentColorClass = (percent) => {
return percent >= 100 ? 'percent-green' : 'percent-blue'
}
return (
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
<div className="user-header">
<span className="user-name">{user.user_name}</span>
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
</div>
<div className="projects-list">
{sortedProjects.map((project, idx) => {
const projectPercent = project.calculated_score || 0
return (
<div key={idx} className="project-row">
<span className="project-name">{project.project_name}</span>
<span className={`project-score ${getPercentColorClass(projectPercent)}`}>{projectPercent.toFixed(0)}%</span>
</div>
)
})}
</div>
</div>
)
}
export default Tracking

View File

@@ -0,0 +1,158 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import './Tracking.css'
function TrackingAccess({ onNavigate, activeTab }) {
const { authFetch } = useAuth()
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [trackers, setTrackers] = useState([])
const [tracked, setTracked] = useState([])
const [loading, setLoading] = useState(true)
const [toastMessage, setToastMessage] = useState(null)
const prevActiveTabRef = useRef(null)
const fetchAccessData = useCallback(async () => {
setLoading(true)
try {
const res = await authFetch('/api/tracking/access')
if (res.ok) {
const data = await res.json()
setTrackers(data.trackers || [])
setTracked(data.tracked || [])
}
} catch (err) {
console.error('Error fetching access data:', err)
} finally {
setLoading(false)
}
}, [authFetch])
// Загрузка списков при монтировании
useEffect(() => {
fetchAccessData()
}, [fetchAccessData])
// Обновление данных при открытии экрана
useEffect(() => {
// Проверяем, что экран только что открылся (activeTab стал 'tracking-access')
if (activeTab === 'tracking-access' && prevActiveTabRef.current !== 'tracking-access') {
fetchAccessData()
}
prevActiveTabRef.current = activeTab
}, [activeTab, fetchAccessData])
const handleCreateInvite = async () => {
setGenerating(true)
try {
const res = await authFetch('/api/tracking/invite', { method: 'POST' })
if (res.ok) {
const data = await res.json()
await navigator.clipboard.writeText(data.invite_url)
setCopied(true)
setToastMessage({ text: 'Ссылка скопирована! Действует 1 час', type: 'success' })
setTimeout(() => setCopied(false), 3000)
} else {
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' })
} finally {
setGenerating(false)
}
}
const handleRemoveTracker = async (relationId) => {
if (!window.confirm('Запретить этому пользователю видеть вашу статистику?')) return
try {
const res = await authFetch(`/api/tracking/trackers/${relationId}`, { method: 'DELETE' })
if (res.ok) {
setTrackers(prev => prev.filter(t => t.relation_id !== relationId))
setToastMessage({ text: 'Доступ отозван', type: 'success' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
}
const handleRemoveTracked = async (relationId) => {
if (!window.confirm('Прекратить отслеживать этого пользователя?')) return
try {
const res = await authFetch(`/api/tracking/tracked/${relationId}`, { method: 'DELETE' })
if (res.ok) {
setTracked(prev => prev.filter(t => t.relation_id !== relationId))
setToastMessage({ text: 'Отслеживание прекращено', type: 'success' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
}
return (
<div className="tracking-access-screen max-w-2xl mx-auto">
{/* Заголовок с крестиком */}
<div className="tracking-header">
<h2 className="text-2xl font-semibold text-gray-800">Управление доступами</h2>
</div>
<button className="close-x-button" onClick={() => window.history.back()}></button>
{/* Секция создания ссылки */}
<div className="access-section">
<h3>Поделиться статистикой</h3>
<p className="section-hint">Создайте одноразовую ссылку (действует 1 час)</p>
<button
className={`create-invite-btn ${copied ? 'copied' : ''}`}
onClick={handleCreateInvite}
disabled={generating}
>
{generating ? 'Создание...' : copied ? '✓ Ссылка скопирована' : 'Создать и скопировать ссылку'}
</button>
</div>
{/* Список: кто меня отслеживает */}
<div className="access-section">
<h3>Меня отслеживают ({trackers.length})</h3>
{trackers.length === 0 ? (
<p className="empty-list">Пока никто не отслеживает вашу статистику</p>
) : (
trackers.map(t => (
<div key={t.relation_id} className="access-item">
<span className="access-item-name">{t.name}</span>
<button className="remove-btn" onClick={() => handleRemoveTracker(t.relation_id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
))
)}
</div>
{/* Список: кого я отслеживаю */}
<div className="access-section">
<h3>Я отслеживаю ({tracked.length})</h3>
{tracked.length === 0 ? (
<p className="empty-list">Вы пока никого не отслеживаете</p>
) : (
tracked.map(t => (
<div key={t.relation_id} className="access-item">
<span className="access-item-name">{t.name}</span>
<button className="remove-btn" onClick={() => handleRemoveTracked(t.relation_id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
))
)}
</div>
{toastMessage && (
<Toast message={toastMessage.text} type={toastMessage.type} onClose={() => setToastMessage(null)} />
)}
</div>
)
}
export default TrackingAccess

View File

@@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './Tracking.css'
function TrackingInviteAccept({ inviteToken, onNavigate }) {
const { authFetch } = useAuth()
const [inviteInfo, setInviteInfo] = useState(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState(null)
// Загрузить информацию о приглашении
useEffect(() => {
const fetchInviteInfo = async () => {
try {
const res = await authFetch(`/api/tracking/invite/${inviteToken}`)
if (res.ok) {
setInviteInfo(await res.json())
} else {
const err = await res.json()
setError(err.error || 'Ссылка недействительна или устарела')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
if (inviteToken) {
fetchInviteInfo()
}
}, [inviteToken, authFetch])
const handleAccept = async () => {
setAccepting(true)
setError(null)
try {
const res = await authFetch(`/api/tracking/invite/${inviteToken}/accept`, { method: 'POST' })
if (res.ok) {
// Успех - переходим на экран отслеживания
onNavigate('tracking')
} else {
const err = await res.json()
setError(err.error || 'Ошибка при принятии приглашения')
}
} catch (err) {
setError('Ошибка при принятии приглашения')
} finally {
setAccepting(false)
}
}
const handleClose = () => {
onNavigate('tracking')
}
if (loading) {
return (
<div className="tracking-invite-screen">
<div className="loading-container">
<div className="spinner"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !inviteInfo) {
return (
<div className="tracking-invite-screen">
<div className="invite-card error-card">
<div className="invite-icon"></div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="secondary-btn" onClick={handleClose}>
Перейти к отслеживанию
</button>
</div>
</div>
)
}
return (
<div className="tracking-invite-screen">
<button className="close-x-button" onClick={handleClose}></button>
<div className="invite-card">
<div className="invite-icon">👁</div>
<h2>Приглашение на отслеживание</h2>
<div className="invite-info">
<p>Вы сможете видеть статистику пользователя:</p>
<p className="user-name-large">{inviteInfo?.user_name}</p>
</div>
{error && <p className="error-inline">{error}</p>}
<button
className="accept-btn"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Принятие...' : 'Начать отслеживать'}
</button>
<button className="cancel-link" onClick={handleClose}>
Отмена
</button>
</div>
</div>
)
}
export default TrackingInviteAccept

View File

@@ -0,0 +1,320 @@
import React from 'react'
import { getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
const parseWeekKey = (weekKey) => {
const [yearStr, weekStr] = weekKey.split('-W')
return { year: Number(yearStr), week: Number(weekStr) }
}
const compareWeekKeys = (a, b) => {
const [yearA, weekA] = a.split('-W').map(Number)
const [yearB, weekB] = b.split('-W').map(Number)
if (yearA !== yearB) {
return yearA - yearB
}
return weekA - weekB
}
// Функция для определения текущей недели в ISO формате
// ISO 8601: неделя начинается с понедельника, первая неделя года - та, в которой есть 4 января
const getCurrentWeek = () => {
const now = new Date()
const date = new Date(now.getTime())
// ISO week calculation
// Set to nearest Thursday: current date + 4 - current day number
// Make Sunday's day number 7
const day = date.getDay() || 7
date.setDate(date.getDate() + 4 - day)
// Get first day of year
const yearStart = new Date(date.getFullYear(), 0, 1)
// Calculate full weeks to nearest Thursday
const week = Math.ceil((((date - yearStart) / 86400000) + 1) / 7)
// ISO year is the year of the Thursday of that week
const year = date.getFullYear()
return { year, week }
}
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
// Определяем текущую неделю
const currentWeek = getCurrentWeek()
const currentWeekKey = formatWeekKey(currentWeek)
// Используем переданный отсортированный список проектов или получаем из данных
const allProjects = allProjectsSorted || (() => {
if (!data || data.length === 0) {
// Если нет данных, используем проекты из currentWeekData
if (currentWeekData && currentWeekData.projects) {
return currentWeekData.projects.map(p => p.project_name || p.name).filter(Boolean)
}
return []
}
const allProjectsSet = new Set()
data.forEach(item => {
allProjectsSet.add(item.project_name)
})
return Array.from(allProjectsSet).sort()
})()
// Группируем данные по неделям
const weeksMap = {}
if (data && data.length > 0) {
data.forEach(item => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && item.project_name !== selectedProject) {
return
}
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!weeksMap[weekKey]) {
weeksMap[weekKey] = []
}
weeksMap[weekKey].push({
projectName: item.project_name,
score: parseFloat(item.total_score) || 0,
minGoalScore: parseFloat(item.min_goal_score) || 0,
maxGoalScore: parseFloat(item.max_goal_score) || 0,
normalizedTotalScore: parseFloat(item.normalized_total_score) || 0
})
})
}
// Добавляем данные текущей недели из currentWeekData, если они отсутствуют в data
if (currentWeekData && currentWeekData.projects && !weeksMap[currentWeekKey]) {
const projects = Array.isArray(currentWeekData.projects)
? currentWeekData.projects
: (currentWeekData.projects?.projects || [])
weeksMap[currentWeekKey] = projects.map(project => ({
projectName: project.project_name || project.name,
score: parseFloat(project.total_score || project.score || 0),
normalizedTotalScore: parseFloat(project.normalized_total_score) || 0
})).filter(p => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && p.projectName !== selectedProject) {
return false
}
return true
})
}
// Получаем все уникальные недели и сортируем их (новые сверху)
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
// Находим индекс текущей недели
const currentWeekIndex = allWeeks.findIndex(w => w === currentWeekKey)
// Разделяем недели на группы
// allWeeks отсортированы от новых к старым (индекс 0 - самая новая)
// Если currentWeekIndex = 2:
// - allWeeks[0, 1] - более новые недели (будущие, если есть)
// - allWeeks[2] - текущая неделя
// - allWeeks[3, 4, ...] - более старые недели (прошлые)
let last4Weeks = [] // Последние 4 недели ДО текущей (более старые)
let next4Weeks = [] // Следующие 4 недели ПОСЛЕ текущей (более новые, если есть)
let currentWeekInData = null
if (currentWeekIndex >= 0) {
// Текущая неделя найдена в данных
// Берем 4 недели ДО текущей (более старые) - это индексы после currentWeekIndex
last4Weeks = allWeeks.slice(currentWeekIndex + 1, currentWeekIndex + 5)
// Берем 4 недели ПОСЛЕ текущей (более новые) - это индексы до currentWeekIndex
next4Weeks = allWeeks.slice(Math.max(0, currentWeekIndex - 4), currentWeekIndex)
// Текущая неделя
currentWeekInData = currentWeekKey
} else {
// Текущая неделя не найдена в данных, но мы её добавили из currentWeekData
if (weeksMap[currentWeekKey]) {
// Добавляем текущую неделю в начало списка (она самая новая)
const weeksWithCurrent = [currentWeekKey, ...allWeeks]
// Последние 4 недели - это первые 4 из старых данных (более старые)
last4Weeks = allWeeks.slice(0, 4)
// Следующие недели после текущей - их нет, так как текущая самая новая
next4Weeks = []
currentWeekInData = currentWeekKey
} else {
// Если текущей недели нет вообще, просто берем последние 4 (самые новые из доступных)
last4Weeks = allWeeks.slice(0, 4)
next4Weeks = []
}
}
// Функция для обработки данных недели
const processWeekData = (weekKey) => {
const { year, week } = parseWeekKey(weekKey)
const weekProjects = weeksMap[weekKey] || []
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
// Используем абсолютные значения (баллы)
// Сортируем проекты так же, как в полной статистике (по priority и min_goal_score)
// Получаем цвет проекта из данных full-statistics, если доступен
const projectsWithData = weekProjects.map(project => {
// Ищем данные проекта в full-statistics для получения цвета и calculated_score
const projectData = data?.find(item =>
item.project_name === project.projectName &&
item.report_year === year &&
item.report_week === week
)
const projectColor = projectData?.color
? getProjectColor(project.projectName, allProjects, projectData.color)
: getProjectColor(project.projectName, allProjects)
return {
...project,
color: projectColor,
normalizedTotalScore: projectData?.normalized_total_score !== undefined ? parseFloat(projectData.normalized_total_score) || 0 : project.normalizedTotalScore || 0
}
})
// Применяем ту же сортировку, что и в FullStatistics
const projectNames = projectsWithData.map(p => p.projectName)
const sortedProjectNames = currentWeekData
? sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
: projectNames
// Пересобираем projectsWithData в правильном порядке
const sortedProjectsWithData = sortedProjectNames.map(name => {
return projectsWithData.find(p => p.projectName === name)
}).filter(Boolean)
return {
weekKey,
year,
week,
projects: sortedProjectsWithData,
totalScore,
isCurrent: weekKey === currentWeekKey
}
}
// Обрабатываем данные для всех недель
const currentWeekDataProcessed = currentWeekInData ? processWeekData(currentWeekInData) : null
// Объединяем все недели кроме текущей
// next4Weeks уже отсортированы от новых к старым (индексы 0..currentWeekIndex-1)
// last4Weeks уже отсортированы от новых к старым (индексы currentWeekIndex+1..)
// Для отображения: старые сверху, новые снизу - нужно перевернуть порядок
// Объединяем: сначала более старые (last4Weeks), потом более новые (next4Weeks)
const allOtherWeeks = [...last4Weeks, ...next4Weeks]
// Переворачиваем, чтобы старые были сверху, новые снизу
const allOtherWeeksData = allOtherWeeks.map(processWeekData).reverse()
// Объединяем все недели для расчета максимального значения
const allWeeksData = [...(currentWeekDataProcessed ? [currentWeekDataProcessed] : []), ...allOtherWeeksData]
// Находим максимальное значение среди всех недель для единой шкалы сравнения
const maxTotalScore = Math.max(...allWeeksData.map(w => w.totalScore), 1)
if (allWeeksData.length === 0) {
return null
}
// Компонент для отображения прогрессбара недели
const WeekProgressBar = ({ weekData }) => {
// Находим выбранный проект в данных недели для отображения normalized значения
const selectedProjectData = selectedProject
? weekData.projects.find(p => p.projectName === selectedProject)
: null
return (
<div className="flex items-center gap-3">
<div className="min-w-[85px] text-sm font-medium text-gray-700">
Неделя {weekData.week}
</div>
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{weekData.totalScore === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
Нет данных
</div>
) : (
<>
{weekData.projects.map((project, index) => {
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
let left = 0
for (let i = 0; i < index; i++) {
left += weekData.projects[i].score
}
const widthPercent = (project.score / maxTotalScore) * 100
const leftPercent = (left / maxTotalScore) * 100
return (
<div
key={project.projectName}
className="absolute h-full transition-all duration-300 hover:opacity-90"
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
backgroundColor: project.color,
}}
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
/>
)
})}
</>
)}
</div>
<div className="min-w-[85px] text-right text-sm text-gray-600 font-medium">
{weekData.totalScore > 0 ? (
<>
{selectedProjectData &&
selectedProjectData.normalizedTotalScore !== undefined &&
selectedProjectData.normalizedTotalScore !== null &&
selectedProjectData.normalizedTotalScore > 0 &&
Math.abs(selectedProjectData.normalizedTotalScore - selectedProjectData.score) > 0.01 ? (
<>
<span className="text-gray-400">
({selectedProjectData.normalizedTotalScore.toFixed(1)})
</span>
<span className="ml-1">
{selectedProjectData.score.toFixed(1)}
</span>
</>
) : (
weekData.totalScore.toFixed(1)
)}
</>
) : '-'}
</div>
</div>
)
}
return (
<div>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>Прогресс недель</h2>
<div className="space-y-3">
{/* Остальные недели (старые сверху, новые снизу) */}
{allOtherWeeksData.map((weekData) => (
<WeekProgressBar key={weekData.weekKey} weekData={weekData} />
))}
{/* Разделитель перед текущей неделей */}
{currentWeekDataProcessed && (
<div className="border-t border-gray-300 my-3"></div>
)}
{/* Текущая неделя (последняя) */}
{currentWeekDataProcessed && (
<>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Текущая неделя</h3>
<WeekProgressBar weekData={currentWeekDataProcessed} />
</>
)}
</div>
</div>
)
}
export default WeekProgressChart

View File

@@ -0,0 +1,447 @@
.wishlist {
max-width: 42rem; /* max-w-2xl = 672px */
margin: 0 auto;
padding-bottom: 5rem;
container-type: inline-size;
}
.wishlist-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
.add-wishlist-button {
background: transparent;
border: 2px dashed #6b8dd6;
border-radius: 18px;
padding: 0;
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;
aspect-ratio: 5 / 6;
position: relative;
}
.add-wishlist-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(107, 141, 214, 0.2);
background-color: rgba(107, 141, 214, 0.05);
border-color: #5b7fc7;
}
.add-wishlist-icon {
font-size: 3rem;
font-weight: bold;
color: #6b8dd6;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.section-divider {
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e5e7eb;
}
.section-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
}
.wishlist .completed-toggle {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
width: 100%;
}
.wishlist .completed-toggle:hover {
color: #3498db;
}
.wishlist .completed-toggle-icon {
font-size: 0.75rem;
}
.loading-completed {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.wishlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.wishlist-project-group {
margin-bottom: 2rem;
}
.wishlist-project-group-title {
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.75rem;
}
.wishlist-project-group-items {
display: flex;
overflow-x: auto;
gap: 1rem;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.wishlist-project-group-items::-webkit-scrollbar {
height: 8px;
}
.wishlist-project-group-items::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.wishlist-project-group-items::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.wishlist-project-group-items::-webkit-scrollbar-thumb:hover {
background: #555;
}
.wishlist-project-group-items .wishlist-card {
/* По умолчанию 1 колонка */
flex: 0 0 100%;
width: 100%;
}
/* 2 колонки: 316px <= width < 482px */
@container (min-width: 316px) {
.wishlist-project-group-items .wishlist-card {
flex: 0 0 calc((100cqw - 1rem) / 2);
width: calc((100cqw - 1rem) / 2);
}
}
/* 3 колонки: 482px <= width < 648px */
@container (min-width: 482px) {
.wishlist-project-group-items .wishlist-card {
flex: 0 0 calc((100cqw - 2rem) / 3);
width: calc((100cqw - 2rem) / 3);
}
}
/* 4 колонки: width >= 648px */
@container (min-width: 648px) {
.wishlist-project-group-items .wishlist-card {
flex: 0 0 calc((100cqw - 3rem) / 4);
width: calc((100cqw - 3rem) / 4);
}
}
.wishlist-no-project {
margin-top: 1.5rem;
}
.wishlist-card {
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
position: relative;
display: flex;
flex-direction: column;
}
.wishlist-card .card-image {
border-radius: 18px;
}
.wishlist-card:hover {
transform: translateY(-2px);
}
.wishlist-card.faded {
opacity: 0.45;
}
.wishlist .card-menu-button {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: rgba(255, 255, 255, 0.7);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 1.1rem;
color: #000000;
z-index: 10;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.wishlist .card-menu-button:hover {
background: rgba(255, 255, 255, 0.9);
color: #333333;
transform: scale(1.1);
}
.card-image {
aspect-ratio: 5 / 6;
background: #f0f0f0;
overflow: hidden;
position: relative;
border-radius: 18px;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 18px;
}
.card-image .placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
background: white;
border-radius: 18px;
}
.card-tasks-badge {
position: absolute;
bottom: 0.5rem;
background: #6b7280;
color: white;
border-radius: 50%;
padding: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
min-width: 1.5rem;
height: 1.5rem;
text-align: center;
z-index: 5;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.card-tasks-badge-left {
left: 0.5rem;
}
.card-tasks-badge-right {
right: 0.5rem;
}
.card-name {
padding: 0.6rem 0 0;
font-weight: 600;
font-size: 1.25rem;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.card-price {
padding: 0;
color: #aaa;
font-size: 0.9rem;
}
.unlock-condition-wrapper {
padding: 0 0 0.5rem;
}
.unlock-condition-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0;
}
.unlock-condition {
display: flex;
align-items: center;
gap: 0.25rem;
color: #888;
font-size: 0.85rem;
flex: 1;
min-width: 0;
}
.unlock-condition .lock-icon {
flex-shrink: 0;
width: 12px;
height: 12px;
}
.condition-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.more-conditions {
padding-left: calc(12px + 0.25rem);
margin-top: -0.15rem;
color: #888;
font-size: 0.85rem;
}
.wishlist-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;
}
.wishlist-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;
}
}
.wishlist-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.wishlist-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.wishlist-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.wishlist-modal-edit,
.wishlist-modal-copy,
.wishlist-modal-complete,
.wishlist-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;
}
.wishlist-modal-edit {
background-color: #3498db;
color: white;
}
.wishlist-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.wishlist-modal-copy {
background-color: #9b59b6;
color: white;
}
.wishlist-modal-copy:hover {
background-color: #8e44ad;
transform: translateY(-1px);
}
.wishlist-modal-complete {
background-color: #27ae60;
color: white;
}
.wishlist-modal-complete:hover {
background-color: #229954;
transform: translateY(-1px);
}
.wishlist-modal-delete {
background-color: #e74c3c;
color: white;
}
.wishlist-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}

View File

@@ -0,0 +1,789 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useAuth } from './auth/AuthContext'
import BoardSelector from './BoardSelector'
import LoadingError from './LoadingError'
import WishlistDetail from './WishlistDetail'
import { sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Wishlist.css'
const API_URL = '/api/wishlist'
const BOARDS_CACHE_KEY = 'wishlist_boards_cache'
const ITEMS_CACHE_KEY = 'wishlist_items_cache'
const SELECTED_BOARD_KEY = 'wishlist_selected_board_id'
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
// Восстанавливаем выбранную доску из localStorage или используем initialBoardId
const getInitialBoardId = () => {
if (initialBoardId) return initialBoardId
return getSavedBoardId()
}
// Получает сохранённую доску из localStorage
const getSavedBoardId = () => {
try {
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
if (saved) {
const boardId = parseInt(saved, 10)
if (!isNaN(boardId)) return boardId
}
} catch (err) {
console.error('Error loading selected board from cache:', err)
}
return null
}
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
const [completedCount, setCompletedCount] = useState(0)
const [loading, setLoading] = useState(true)
const [boardsLoading, setBoardsLoading] = useState(true)
const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
const [currentWeekData, setCurrentWeekData] = useState(null)
const fetchingRef = useRef(false)
const fetchingCompletedRef = useRef(false)
const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive)
// Обёртка для setSelectedBoardId с сохранением в localStorage
const setSelectedBoardId = (boardId) => {
setSelectedBoardIdState(boardId)
try {
if (boardId) {
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
} else {
localStorage.removeItem(SELECTED_BOARD_KEY)
}
} catch (err) {
console.error('Error saving selected board to cache:', err)
}
}
// Загрузка досок из кэша
const loadBoardsFromCache = () => {
try {
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
setBoards(data.boards || [])
// Проверяем, что сохранённая доска существует в списке
if (selectedBoardId) {
const boardExists = data.boards?.some(b => b.id === selectedBoardId)
if (!boardExists && data.boards?.length > 0) {
setSelectedBoardId(data.boards[0].id)
}
} else if (data.boards?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data.boards[0].id)
}
}
return true
}
} catch (err) {
console.error('Error loading boards from cache:', err)
}
return false
}
// Сохранение досок в кэш
const saveBoardsToCache = (boardsData) => {
try {
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({
boards: boardsData,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving boards to cache:', err)
}
}
// Загрузка желаний из кэша (по board_id)
const loadItemsFromCache = (boardId) => {
try {
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
if (cached) {
const data = JSON.parse(cached)
setItems(data.items || [])
setCompletedCount(data.completedCount || 0)
return true
}
} catch (err) {
console.error('Error loading items from cache:', err)
}
return false
}
// Сохранение желаний в кэш
const saveItemsToCache = (boardId, itemsData, count) => {
try {
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
items: itemsData,
completedCount: count,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving items to cache:', err)
}
}
// Загрузка списка досок
const fetchBoards = async () => {
try {
const response = await authFetch(`${API_URL}/boards`)
if (response.ok) {
const data = await response.json()
setBoards(data || [])
saveBoardsToCache(data || [])
// Проверяем, что выбранная доска существует в списке
if (selectedBoardId) {
const boardExists = data?.some(b => b.id === selectedBoardId)
if (!boardExists && data?.length > 0) {
// Сохранённая доска не существует, выбираем первую
setSelectedBoardId(data[0].id)
}
} else if (data?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data[0].id)
}
}
}
} catch (err) {
console.error('Error fetching boards:', err)
} finally {
setBoardsLoading(false)
}
}
// Загрузка желаний выбранной доски
const fetchItems = async () => {
if (!selectedBoardId || fetchingRef.current) return
fetchingRef.current = true
try {
const hasDataInState = items.length > 0 || completedCount > 0
if (!hasDataInState) {
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (!cacheLoaded) {
setLoading(true)
}
}
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
if (!response.ok) {
throw new Error('Ошибка при загрузке желаний')
}
const data = await response.json()
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
const count = data.completed_count || 0
setItems(allItems)
setCompletedCount(count)
saveItemsToCache(selectedBoardId, allItems, count)
setError('')
} catch (err) {
setError(err.message)
if (!loadItemsFromCache(selectedBoardId)) {
setItems([])
setCompletedCount(0)
}
} finally {
setLoading(false)
fetchingRef.current = false
}
}
// Загрузка завершённых для текущей доски
const fetchCompleted = async () => {
if (fetchingCompletedRef.current || !selectedBoardId) return
fetchingCompletedRef.current = true
try {
setCompletedLoading(true)
// Используем новый API для получения завершённых на доске
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
if (!response.ok) {
throw new Error('Ошибка при загрузке завершённых желаний')
}
const data = await response.json()
const completedData = Array.isArray(data) ? data : []
setCompleted(completedData)
} catch (err) {
console.error('Error fetching completed items:', err)
setCompleted([])
} finally {
setCompletedLoading(false)
fetchingCompletedRef.current = false
}
}
// Загрузка данных текущей недели для сортировки проектов
const fetchCurrentWeek = async () => {
try {
const response = await authFetch('/api/current-week')
if (response.ok) {
const data = await response.json()
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
if (Array.isArray(data) && data.length > 0) {
setCurrentWeekData(data[0])
} else if (data && typeof data === 'object') {
setCurrentWeekData(data)
}
}
} catch (err) {
console.error('Error loading current week data:', err)
}
}
// Первая инициализация
useEffect(() => {
if (!initialFetchDoneRef.current) {
initialFetchDoneRef.current = true
// Загружаем доски из кэша
const boardsCacheLoaded = loadBoardsFromCache()
if (boardsCacheLoaded) {
setBoardsLoading(false)
}
// Загружаем доски с сервера
fetchBoards()
// Загружаем данные текущей недели для сортировки проектов
fetchCurrentWeek()
}
}, [])
// Загружаем желания при смене доски
useEffect(() => {
if (selectedBoardId) {
// Сбрасываем состояние
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Пробуем загрузить из кэша
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (cacheLoaded) {
setLoading(false)
}
// Загружаем свежие данные
fetchItems()
}
}, [selectedBoardId])
// Обновление при активации таба
useEffect(() => {
const wasActive = prevIsActiveRef.current
prevIsActiveRef.current = isActive
if (!initialFetchDoneRef.current) return
if (isActive && !wasActive) {
fetchBoards()
if (selectedBoardId) {
fetchItems()
}
}
}, [isActive])
// Обновление при refreshTrigger
useEffect(() => {
if (refreshTrigger > 0 && selectedBoardId) {
// Очищаем кэш для текущей доски, чтобы загрузить свежие данные
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
fetchBoards()
fetchItems()
if (completedExpanded && completedCount > 0) {
fetchCompleted()
}
}
}, [refreshTrigger, selectedBoardId])
// Обновление при initialBoardId (когда создана новая доска или переход по ссылке)
useEffect(() => {
if (initialBoardId && initialBoardId !== selectedBoardId) {
// Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку
fetchingRef.current = false
// Обновляем список досок (чтобы новая доска появилась)
fetchBoards().then(() => {
// Переключаемся на новую доску после обновления списка
// Это вызовет useEffect для selectedBoardId, который загрузит данные
setSelectedBoardId(initialBoardId)
})
}
}, [initialBoardId])
// Обработка удаления доски - выбираем первую доступную
useEffect(() => {
if (boardDeleted && boards.length > 0) {
// Очищаем текущие данные
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Обновляем список досок и выбираем первую
fetchBoards().then(() => {
// fetchBoards обновит boards, но мы уже в этом useEffect
// selectedBoardId обновится автоматически в useEffect ниже
})
}
}, [boardDeleted])
// Если текущая доска больше не существует в списке - выбираем первую
useEffect(() => {
if (boards.length > 0 && selectedBoardId) {
const boardExists = boards.some(b => b.id === selectedBoardId)
if (!boardExists) {
setSelectedBoardId(boards[0].id)
}
}
}, [boards, selectedBoardId])
const handleBoardChange = (boardId) => {
setSelectedBoardId(boardId)
}
const handleBoardEdit = () => {
const board = boards.find(b => b.id === selectedBoardId)
if (board?.is_owner) {
onNavigate?.('board-form', { boardId: selectedBoardId })
} else {
// Показать подтверждение выхода
handleLeaveBoard()
}
}
const handleLeaveBoard = async () => {
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
try {
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
method: 'POST'
})
if (response.ok) {
// Убираем доску из списка
const newBoards = boards.filter(b => b.id !== selectedBoardId)
setBoards(newBoards)
saveBoardsToCache(newBoards)
// Выбираем первую доску
if (newBoards.length > 0) {
setSelectedBoardId(newBoards[0].id)
} else {
setSelectedBoardId(null)
setItems([])
}
}
} catch (err) {
console.error('Error leaving board:', err)
}
}
const handleAddBoard = () => {
onNavigate?.('board-form', { boardId: null })
}
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
if (newExpanded && completedCount > 0) {
fetchCompleted()
}
}
const handleAddClick = () => {
// Если selectedBoardId равен null, но есть доски, используем первую доску
// Если доски еще не загружены, используем initialBoardId
const boardIdToUse = selectedBoardId || (boards.length > 0 ? boards[0].id : initialBoardId)
onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: boardIdToUse })
}
const handleItemClick = (item) => {
setSelectedWishlistForDetail(item.id)
}
const handleCloseDetail = () => {
setSelectedWishlistForDetail(null)
}
const handleMenuClick = (item, e) => {
e.stopPropagation()
setSelectedItem(item)
}
const handleEdit = () => {
if (selectedItem) {
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
setSelectedItem(null)
}
}
const handleDelete = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
setSelectedItem(null)
await fetchItems()
if (completedExpanded) {
await fetchCompleted()
}
} catch (err) {
setError(err.message)
setSelectedItem(null)
}
}
const handleCopy = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(errorText || 'Ошибка при копировании')
}
const newItem = await response.json()
setSelectedItem(null)
// Очищаем кэш для текущей доски, чтобы новое желание появилось в списке
if (selectedBoardId) {
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
}
// Обновляем список
await fetchItems()
// Открываем форму редактирования для нового желания
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
} catch (err) {
console.error('Error copying wishlist item:', err)
setError(err.message || 'Ошибка при копировании')
setSelectedItem(null)
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const findFirstUnmetCondition = (item) => {
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
return null
}
for (const condition of item.unlock_conditions) {
let isMet = false
if (condition.type === 'task_completion') {
isMet = condition.task_completed === true
} else if (condition.type === 'project_points') {
const currentPoints = condition.current_points || 0
const requiredPoints = condition.required_points || 0
isMet = currentPoints >= requiredPoints
}
if (!isMet) {
return condition
}
}
return null
}
const renderUnlockCondition = (item) => {
if (item.completed) return null
const condition = findFirstUnmetCondition(item)
if (!condition) return null
let conditionText = ''
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const remainingPoints = Math.max(0, requiredPoints - currentPoints)
const project = condition.project_name || 'Проект'
// Показываем оставшиеся баллы в формате "33 в Привлекательность"
// Дата начала отсчёта уже учтена в current_points на бэкенде
conditionText = `${Math.round(remainingPoints)} в ${project}`
}
return (
<div className="unlock-condition-wrapper">
<div className="unlock-condition-line">
<div className="unlock-condition">
<svg className="lock-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
</svg>
<span className="condition-text">{conditionText}</span>
</div>
</div>
</div>
)
}
// Группируем желания по группам
const groupedItems = useMemo(() => {
const groups = {}
const noGroupItems = []
items.forEach(item => {
if (item.group_name && item.group_name.trim()) {
const groupName = item.group_name.trim()
if (!groups[groupName]) {
groups[groupName] = {
groupName: groupName,
items: []
}
}
groups[groupName].items.push(item)
} else {
noGroupItems.push(item)
}
})
// Сортируем группы по алфавиту
const sortedGroups = Object.values(groups).sort((a, b) =>
a.groupName.localeCompare(b.groupName)
)
return { groups: sortedGroups, noGroupItems }
}, [items])
const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed
return (
<div
key={item.id}
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
onClick={() => handleItemClick(item)}
>
<button
className="card-menu-button"
onClick={(e) => handleMenuClick(item, e)}
title="Меню"
>
</button>
<div className="card-image">
{item.image_url ? (
<img src={item.image_url} alt={item.name} />
) : (
<div className="placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
)}
</div>
<div className="card-name">{item.name}</div>
{(() => {
const unmetCondition = findFirstUnmetCondition(item)
if (unmetCondition && !item.completed) {
return renderUnlockCondition(item)
}
if (item.price) {
return <div className="card-price">{formatPrice(item.price)}</div>
}
return null
})()}
</div>
)
}
if (error && items.length === 0 && !boardsLoading && !loading) {
return (
<div className="wishlist">
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
/>
<LoadingError onRetry={() => fetchItems()} />
</div>
)
}
return (
<div className="wishlist">
{/* Селектор доски */}
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
/>
{/* Основной список */}
{boardsLoading && loading ? (
<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>
) : loading ? (
<div className="wishlist-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<>
{/* Группы проектов */}
{groupedItems.groups.map(group => (
<div key={group.groupName} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.groupName}</div>
<div className="wishlist-project-group-items">
{group.items.map(renderItem)}
</div>
</div>
))}
{/* Желания без группы */}
{groupedItems.noGroupItems.length > 0 && (
<div className="wishlist-no-project">
<div className="wishlist-grid">
{groupedItems.noGroupItems.map(renderItem)}
</div>
</div>
)}
{/* Завершённые */}
{completedCount > 0 && (
<>
<div className="section-divider">
<button
className="completed-toggle"
onClick={handleToggleCompleted}
>
<span className="completed-toggle-icon">
{completedExpanded ? '▼' : '▶'}
</span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<>
{completedLoading ? (
<div className="loading-completed">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="wishlist-grid">
{completed.map(renderItem)}
</div>
)}
</>
)}
</>
)}
</>
)}
{/* Модальное окно для действий */}
{selectedItem && (
<div className="wishlist-modal-overlay" onClick={() => setSelectedItem(null)}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{selectedItem.name}</h3>
</div>
<div className="wishlist-modal-actions">
<button className="wishlist-modal-edit" onClick={handleEdit}>
Редактировать
</button>
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Модальное окно для деталей желания */}
{selectedWishlistForDetail && (
<WishlistDetail
wishlistId={selectedWishlistForDetail}
onNavigate={onNavigate}
boardId={selectedBoardId}
onRefresh={async () => {
await fetchItems()
if (completedExpanded) {
await fetchCompleted()
}
}}
onClose={handleCloseDetail}
/>
)}
</div>
)
}
export default Wishlist

View File

@@ -0,0 +1,458 @@
/* Модальное окно */
.wishlist-detail-modal-overlay {
position: fixed !important;
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: 9999 !important;
padding: 1rem;
}
.wishlist-detail-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wishlist-detail-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem 0.5rem 1.5rem;
}
.wishlist-detail-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
flex: 1;
min-width: 0;
transition: opacity 0.2s;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.wishlist-detail-title:hover {
opacity: 0.7;
}
.wishlist-detail-edit-icon {
color: #6b7280;
flex-shrink: 0;
transition: color 0.2s;
display: inline-block;
vertical-align: middle;
margin-left: 0.5rem;
}
.wishlist-detail-title:hover .wishlist-detail-edit-icon {
color: #1f2937;
}
.wishlist-detail-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
flex-shrink: 0;
margin-left: 1rem;
}
.wishlist-detail-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.wishlist-detail-modal-content {
padding: 0.75rem 1.5rem 1.25rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.wishlist-detail {
/* Оставляем для обратной совместимости */
}
/* Контент теперь внутри модального окна, убираем лишние стили */
.wishlist-detail-image {
width: 100%;
aspect-ratio: 5 / 6;
border-radius: 12px;
overflow: hidden;
margin-bottom: 0.5rem;
background: #f0f0f0;
}
.wishlist-detail-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wishlist-detail-price {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0;
}
.wishlist-detail-link {
margin-bottom: 0.5rem;
}
.wishlist-detail-link a {
color: #3498db;
text-decoration: none;
font-size: 1rem;
transition: color 0.2s;
}
.wishlist-detail-link a:hover {
color: #2980b9;
text-decoration: underline;
}
.wishlist-detail-conditions {
margin-top: 1rem;
}
.wishlist-detail-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin: 0 0 0.75rem 0;
}
.wishlist-detail-condition {
padding: 0.375rem 0.5rem;
font-size: 0.95rem;
border-radius: 8px;
margin-bottom: 0;
}
.wishlist-detail-condition.met {
color: #27ae60;
}
.wishlist-detail-condition.not-met {
color: #888;
}
.wishlist-detail-condition.clickable {
transition: background-color 0.2s, transform 0.1s;
}
.wishlist-detail-condition.clickable:hover {
background-color: rgba(0, 0, 0, 0.05);
transform: translateX(2px);
}
.condition-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.125rem;
}
.condition-icon {
flex-shrink: 0;
}
.condition-text {
flex: 1;
}
.condition-task-date {
margin-top: 0.125rem;
margin-left: calc(16px + 0.5rem);
font-size: 0.85rem;
color: #6b7280;
}
.condition-progress {
margin-top: 0.125rem;
margin-left: calc(16px + 0.5rem);
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.125rem;
}
.progress-fill {
height: 100%;
background-color: #3498db;
border-radius: 4px;
transition: width 0.3s ease;
}
.wishlist-detail-condition.met .progress-fill {
background-color: #27ae60;
}
.progress-text {
font-size: 0.85rem;
color: #666;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.progress-remaining {
color: #e74c3c;
font-weight: 500;
}
.wishlist-detail-actions {
display: flex;
flex-direction: row;
gap: 0.75rem;
margin-top: 0.75rem;
align-items: center;
}
.wishlist-detail-edit-button,
.wishlist-detail-complete-button,
.wishlist-detail-uncomplete-button,
.wishlist-detail-delete-button {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.wishlist-detail-edit-button {
background-color: #3498db;
color: white;
}
.wishlist-detail-edit-button:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.wishlist-detail-complete-button {
flex: 1;
background-color: #27ae60;
color: white;
}
.wishlist-detail-complete-button:hover:not(:disabled) {
background-color: #229954;
transform: translateY(-1px);
}
.wishlist-detail-complete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wishlist-detail-create-task-button {
padding: 0.75rem;
background-color: transparent;
color: #27ae60;
border: 2px solid #27ae60;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 3rem;
height: 3rem;
}
.wishlist-detail-create-task-button:hover {
background-color: rgba(39, 174, 96, 0.1);
transform: translateY(-1px);
}
.wishlist-detail-tasks-badge {
position: absolute;
bottom: -0.25rem;
left: -0.25rem;
background: #6b7280;
color: white;
border-radius: 50%;
padding: 0.2rem;
font-size: 0.7rem;
font-weight: 600;
min-width: 1.25rem;
height: 1.25rem;
text-align: center;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
}
.wishlist-detail-linked-task {
margin-top: 0.75rem;
}
.linked-task-label-header {
font-size: 0.9rem;
color: #374151;
font-weight: 500;
margin-bottom: 0.5rem;
}
.wishlist-detail-linked-task .task-item {
margin: 0;
}
.wishlist-detail-linked-task .task-item-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.wishlist-detail-linked-task .task-name-container {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
overflow: hidden;
}
.wishlist-detail-linked-task .task-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.wishlist-detail-linked-task .task-unlink-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #9ca3af;
font-size: 1rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
flex-shrink: 0;
}
.wishlist-detail-linked-task .task-unlink-button:hover {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.wishlist-detail-uncomplete-button {
flex: 1;
background-color: #f1c40f;
color: white;
}
.wishlist-detail-uncomplete-button:hover:not(:disabled) {
background-color: #f39c12;
transform: translateY(-1px);
}
.wishlist-detail-uncomplete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wishlist-detail-delete-button {
background-color: #e74c3c;
color: white;
}
.wishlist-detail-delete-button:hover:not(:disabled) {
background-color: #c0392b;
transform: translateY(-1px);
}
.wishlist-detail-delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wishlist-detail-bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: white;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 1500;
}
.wishlist-detail-bottom-actions .wishlist-detail-uncomplete-button {
flex: 1;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background-color: #f1c40f;
color: white;
}
.wishlist-detail-bottom-actions .wishlist-detail-uncomplete-button:hover:not(:disabled) {
background-color: #f39c12;
transform: translateY(-1px);
}
.wishlist-detail-bottom-actions .wishlist-detail-uncomplete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 2rem;
color: #888;
}

View File

@@ -0,0 +1,755 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import TaskDetail from './TaskDetail'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './WishlistDetail.css'
import './TaskList.css'
const API_URL = '/api/wishlist'
function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, previousTab }) {
const { authFetch, user } = useAuth()
const [wishlistItem, setWishlistItem] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingWishlist, setLoadingWishlist] = useState(true)
const [error, setError] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const fetchWishlistDetail = useCallback(async () => {
try {
setLoadingWishlist(true)
setLoading(true)
setError(null)
const response = await authFetch(`${API_URL}/${wishlistId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки желания')
}
const data = await response.json()
setWishlistItem(data)
} catch (err) {
setError(err.message)
console.error('Error fetching wishlist detail:', err)
} finally {
setLoading(false)
setLoadingWishlist(false)
}
}, [wishlistId, authFetch])
useEffect(() => {
if (wishlistId) {
fetchWishlistDetail()
} else {
setWishlistItem(null)
setLoading(true)
setLoadingWishlist(true)
setError(null)
}
}, [wishlistId, fetchWishlistDetail])
const handleEdit = () => {
// Сбрасываем флаг, чтобы handleClose не вызвал history.back()
// handleTabChange заменит запись модального окна через replaceState
historyPushedForWishlistRef.current = false
onClose?.()
onNavigate?.('wishlist-form', { wishlistId: wishlistId, boardId: boardId })
}
const handleComplete = async () => {
if (!wishlistItem || !wishlistItem.unlocked) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/complete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при завершении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error completing wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleUncomplete = async () => {
if (!wishlistItem || !wishlistItem.completed) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/uncomplete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при возобновлении желания')
}
if (onRefresh) {
onRefresh()
}
fetchWishlistDetail()
} catch (err) {
console.error('Error uncompleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при возобновлении желания', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleDelete = async () => {
if (!wishlistItem) return
if (!window.confirm('Вы уверены, что хотите удалить это желание?')) {
return
}
setIsDeleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error deleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
} finally {
setIsDeleting(false)
}
}
const handleCreateTask = () => {
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
onNavigate?.('task-form', { wishlistId: wishlistId })
}
const handleTaskCheckmarkClick = (e) => {
e.stopPropagation()
if (wishlistItem?.linked_task) {
setSelectedTaskForDetail(wishlistItem.linked_task.id)
}
}
const handleTaskItemClick = () => {
if (wishlistItem?.linked_task) {
setSelectedTaskForDetail(wishlistItem.linked_task.id)
}
}
const handleConditionTaskClick = async (condition) => {
if (condition.type !== 'task_completion' || !condition.task_id) {
return
}
try {
// Загружаем информацию о задаче
const response = await authFetch(`/api/tasks/${condition.task_id}`)
if (!response.ok) {
throw new Error('Ошибка при загрузке задачи')
}
const taskDetail = await response.json()
// Проверяем, является ли задача тестом
const isTest = taskDetail.task?.config_id != null
if (isTest) {
// Для задач-тестов открываем экран прохождения теста
if (taskDetail.task.config_id) {
onNavigate?.('test', {
configId: taskDetail.task.config_id,
taskId: taskDetail.task.id,
maxCards: taskDetail.max_cards
})
}
} else {
// Для обычных задач открываем модальное окно выполнения
setSelectedTaskForDetail(condition.task_id)
}
} catch (err) {
console.error('Failed to load task details:', err)
setToastMessage({ text: 'Ошибка при загрузке задачи', type: 'error' })
}
}
const handleCloseDetail = (skipHistoryBack = false) => {
// Если skipHistoryBack = true (например, при навигации на форму редактирования),
// закрываем модальные окна без удаления записей из истории
// App.jsx сам обработает навигацию и заменит запись task-detail на task-form через replaceState
// Запись wishlist-detail останется в истории, но экран будет закрыт
if (skipHistoryBack) {
// Сохраняем флаг перед сбросом
const hadWishlistHistory = historyPushedForWishlistRef.current
// Закрываем модальные окна
historyPushedForTaskRef.current = false
setSelectedTaskForDetail(null)
historyPushedForWishlistRef.current = false
// Закрываем экран желания через onClose
// Навигация на task-form уже происходит в TaskDetail, поэтому не вызываем onNavigate здесь
// App.jsx обработает навигацию и заменит запись task-detail на task-form через replaceState
if (hadWishlistHistory && onClose) {
onClose()
}
} else if (historyPushedForTaskRef.current) {
window.history.back()
} else {
historyPushedForTaskRef.current = false
setSelectedTaskForDetail(null)
}
}
// Добавляем запись в историю при открытии модальных окон и обрабатываем "назад"
const historyPushedForWishlistRef = useRef(false)
const historyPushedForTaskRef = useRef(false)
const wishlistIdRef = useRef(wishlistId)
const selectedTaskForDetailRef = useRef(selectedTaskForDetail)
// Обновляем refs при изменении значений
useEffect(() => {
wishlistIdRef.current = wishlistId
selectedTaskForDetailRef.current = selectedTaskForDetail
}, [wishlistId, selectedTaskForDetail])
useEffect(() => {
if (wishlistId && !historyPushedForWishlistRef.current) {
// Добавляем запись в историю при открытии модального окна WishlistDetail
window.history.pushState({ modalOpen: true, type: 'wishlist-detail' }, '', window.location.href)
historyPushedForWishlistRef.current = true
} else if (!wishlistId) {
historyPushedForWishlistRef.current = false
}
if (selectedTaskForDetail && !historyPushedForTaskRef.current) {
// Добавляем запись в историю при открытии вложенного модального окна TaskDetail
window.history.pushState({ modalOpen: true, type: 'task-detail', nested: true }, '', window.location.href)
historyPushedForTaskRef.current = true
} else if (!selectedTaskForDetail) {
historyPushedForTaskRef.current = false
}
if (!wishlistId && !selectedTaskForDetail) return
const handlePopState = (event) => {
// Проверяем наличие модальных окон в DOM
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay')
// Используем refs для получения актуального состояния
const currentTaskDetail = selectedTaskForDetailRef.current
const currentWishlistId = wishlistIdRef.current
// Сначала проверяем вложенное модальное окно TaskDetail
if (currentTaskDetail || taskDetailModal) {
setSelectedTaskForDetail(null)
historyPushedForTaskRef.current = false
// Возвращаем запись для WishlistDetail
if (currentWishlistId || wishlistDetailModal) {
window.history.pushState({ modalOpen: true, type: 'wishlist-detail' }, '', window.location.href)
}
return
}
// Если открыто модальное окно WishlistDetail, закрываем его
if (currentWishlistId || wishlistDetailModal) {
if (onClose) {
onClose()
} else {
// Возвращаемся на предыдущий таб, если он был сохранен, иначе на wishlist
if (previousTab) {
if (boardId) {
onNavigate?.(previousTab, { boardId })
} else {
onNavigate?.(previousTab)
}
} else if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist')
}
}
historyPushedForWishlistRef.current = false
// Следующее нажатие "назад" обработается App.jsx нормально
return
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [wishlistId, selectedTaskForDetail, onClose, onNavigate, previousTab, boardId])
const handleClose = () => {
// Если была добавлена запись в историю, удаляем её через history.back()
// Обработчик popstate закроет модальное окно
if (historyPushedForWishlistRef.current) {
window.history.back()
} else if (onClose) {
onClose()
} else {
// Возвращаемся на предыдущий таб, если он был сохранен, иначе на wishlist
if (previousTab) {
// Сохраняем boardId при возврате на предыдущий таб
if (boardId) {
onNavigate?.(previousTab, { boardId })
} else {
onNavigate?.(previousTab)
}
} else if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist')
}
}
}
const handleTaskCompleted = () => {
setToastMessage({ text: 'Задача выполнена', type: 'success' })
// После выполнения задачи желание тоже завершается, перенаправляем на список
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
}
const handleDeleteTask = async (e) => {
e.stopPropagation()
if (!wishlistItem?.linked_task || wishlistItem?.completed) return
if (!window.confirm('Удалить задачу, связанную с желанием?')) {
return
}
try {
// Удаляем задачу (помечаем как удалённую)
const deleteResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`, {
method: 'DELETE',
})
if (!deleteResponse.ok) {
const errorData = await deleteResponse.json().catch(() => ({}))
throw new Error(errorData.message || errorData.error || 'Ошибка при удалении задачи')
}
setToastMessage({ text: 'Задача удалена', type: 'success' })
// Обновляем данные желания
fetchWishlistDetail()
if (onRefresh) {
onRefresh()
}
} catch (err) {
console.error('Error deleting task:', err)
setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' })
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const renderUnlockConditions = () => {
if (!wishlistItem || !wishlistItem.unlock_conditions || wishlistItem.unlock_conditions.length === 0) {
return null
}
return (
<div className="wishlist-detail-conditions">
<h3 className="wishlist-detail-section-title">Цели:</h3>
{wishlistItem.unlock_conditions.map((condition, index) => {
let conditionText = ''
let progress = null
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
const isCompleted = condition.task_completed === true
progress = {
type: 'task',
completed: isCompleted
}
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const project = condition.project_name || 'Проект'
let dateText = ''
if (condition.start_date) {
const date = new Date(condition.start_date + 'T00:00:00')
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
} else {
dateText = ' за всё время'
}
conditionText = `${requiredPoints} в ${project}${dateText}`
const remaining = Math.max(0, requiredPoints - currentPoints)
progress = {
type: 'points',
current: currentPoints,
required: requiredPoints,
remaining: remaining,
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
}
}
// Проверяем каждое условие индивидуально
let isMet = false
if (progress?.type === 'task') {
isMet = progress.completed === true
} else if (progress?.type === 'points') {
isMet = progress.current >= progress.required
}
return (
<div
key={index}
className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'} ${condition.type === 'task_completion' && condition.task_id ? 'clickable' : ''}`}
onClick={condition.type === 'task_completion' && condition.task_id ? () => handleConditionTaskClick(condition) : undefined}
style={condition.type === 'task_completion' && condition.task_id ? { cursor: 'pointer' } : {}}
>
<div className="condition-header">
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
{isMet ? (
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
) : (
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
)}
</svg>
<span className="condition-text">{conditionText}</span>
</div>
{/* Показываем дату для целей-задач, если next_show_at > сегодня */}
{condition.type === 'task_completion' && condition.task_next_show_at && (() => {
const showDate = new Date(condition.task_next_show_at)
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
const today = new Date()
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
// Показываем только если дата > сегодня
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
return null
}
const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
let dateText
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="condition-task-date">
{dateText}
</div>
)
})()}
{progress && progress.type === 'points' && !isMet && (
<div className="condition-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress.percentage}%` }}
></div>
</div>
<div className="progress-text">
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
{progress.remaining > 0 && (
<span className="progress-remaining">
Осталось: {Math.round(progress.remaining)}
{condition.weeks_text && ` (${condition.weeks_text})`}
</span>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
const modalContent = (
<div className="wishlist-detail-modal-overlay" onClick={handleClose}>
<div className="wishlist-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-detail-modal-header">
<h2
className="wishlist-detail-title"
onClick={wishlistItem ? handleEdit : undefined}
style={{ cursor: wishlistItem ? 'pointer' : 'default' }}
>
{loadingWishlist ? 'Загрузка...' : error ? 'Ошибка' : wishlistItem ? wishlistItem.name : 'Желание'}
{wishlistItem && (
<svg
className="wishlist-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
)}
</h2>
<button onClick={handleClose} className="wishlist-detail-close-button">
</button>
</div>
<div className="wishlist-detail-modal-content">
{loadingWishlist && (
<div className="flex justify-center items-center py-8">
<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>
)}
{error && !loadingWishlist && (
<LoadingError onRetry={fetchWishlistDetail} />
)}
{!loadingWishlist && !error && wishlistItem && (
<>
{/* Изображение */}
{wishlistItem.image_url && (
<div className="wishlist-detail-image">
<img src={wishlistItem.image_url} alt={wishlistItem.name} />
</div>
)}
{/* Цена */}
{wishlistItem.price && (
<div className="wishlist-detail-price">
{formatPrice(wishlistItem.price)}
</div>
)}
{/* Ссылка */}
{wishlistItem.link && (() => {
try {
const url = new URL(wishlistItem.link)
const host = url.host.replace(/^www\./, '') // Убираем www. если есть
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
{host}
</a>
</div>
)
} catch {
// Если URL некорректный, показываем оригинальный текст
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
Открыть ссылку
</a>
</div>
)
}
})()}
{/* Условия разблокировки */}
{renderUnlockConditions()}
{/* Связанная задача или кнопки действий */}
{wishlistItem.unlocked && (
<>
{wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
<div className="wishlist-detail-linked-task">
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
<div
className="task-item"
onClick={handleTaskItemClick}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={handleTaskCheckmarkClick}
title="Выполнить задачу"
>
<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" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{wishlistItem.linked_task.name}
</div>
{/* Показываем дату только для выполненных задач (next_show_at > сегодня) */}
{wishlistItem.linked_task.next_show_at && (() => {
const showDate = new Date(wishlistItem.linked_task.next_show_at)
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
const today = new Date()
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
// Показываем только если дата > сегодня
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
return null
}
const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
let dateText
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div>
</div>
{wishlistItem && !wishlistItem.completed && (
<div className="task-actions">
<button
className="task-delete-button"
onClick={handleDeleteTask}
title="Удалить задачу"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
)}
</div>
</div>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</div>
) : (
<div className="wishlist-detail-actions">
{wishlistItem.completed ? (
<button
onClick={handleUncomplete}
disabled={isCompleting}
className="wishlist-detail-uncomplete-button"
>
{isCompleting ? 'Возобновление...' : 'Возобновить'}
</button>
) : (
<>
<button
onClick={handleComplete}
disabled={isCompleting}
className="wishlist-detail-complete-button"
>
{isCompleting ? 'Завершение...' : 'Завершить'}
</button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={handleCreateTask}
className="wishlist-detail-create-task-button"
title="Создать задачу"
>
<svg width="20" height="20" 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>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</>
)}
</div>
)}
</>
)}
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={() => {
fetchWishlistDetail()
if (onRefresh) onRefresh()
}}
onTaskCompleted={handleTaskCompleted}
onNavigate={onNavigate}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default WishlistDetail

View File

@@ -0,0 +1,713 @@
.wishlist-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.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;
}
.wishlist-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.wishlist-form form {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.image-preview {
position: relative;
width: 100%;
max-width: 400px;
margin-top: 0.5rem;
}
.image-preview img {
width: 100%;
height: auto;
border-radius: 0.375rem;
aspect-ratio: 5 / 6;
object-fit: cover;
}
.remove-image-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(231, 76, 60, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.remove-image-button:hover {
background: rgba(192, 57, 43, 1);
}
.cropper-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.cropper-container {
position: relative;
width: 100%;
max-width: 600px;
height: 450px;
background: white;
border-radius: 0.5rem;
overflow: hidden;
}
.cropper-controls {
margin-top: 1rem;
background: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 600px;
}
.cropper-controls label {
display: flex;
align-items: center;
gap: 1rem;
color: white;
}
.cropper-controls input[type="range"] {
flex: 1;
}
.cropper-actions {
margin-top: 1rem;
display: flex;
gap: 1rem;
width: 100%;
max-width: 600px;
}
.cropper-actions button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cropper-actions button:first-child {
background: #6b7280;
color: white;
}
.cropper-actions button:first-child:hover {
background: #4b5563;
}
.cropper-actions button:last-child {
background: #3498db;
color: white;
}
.cropper-actions button:last-child:hover {
background: #2980b9;
}
.conditions-list {
margin-bottom: 0.5rem;
}
.condition-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
}
.condition-item-text {
flex: 1;
cursor: pointer;
padding: 0.25rem 0;
transition: color 0.2s;
}
.condition-item-text:hover {
color: #3498db;
}
.condition-item-text.condition-item-other-user {
color: #95a5a6;
font-style: italic;
cursor: not-allowed;
}
.condition-item-text.condition-item-other-user:hover {
color: #95a5a6;
}
.remove-condition-button {
background: #e74c3c;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.remove-condition-button:hover {
background: #c0392b;
}
.add-condition-button {
width: 100%;
padding: 0.75rem;
background: #f3f4f6;
border: 1px dashed #9ca3af;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
color: #374151;
transition: all 0.2s;
}
.add-condition-button:hover {
background: #e5e7eb;
border-color: #6b7280;
}
.condition-form-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: 1700;
}
.condition-form {
background: white;
border-radius: 0.5rem;
padding: 0;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.condition-form-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.condition-form h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.condition-form-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
transition: background-color 0.2s, color 0.2s;
}
.condition-form-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.condition-form form {
padding: 1.5rem 1.5rem 0.75rem 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
margin-bottom: 0.125rem;
}
.calculated-weeks-info {
margin-top: 0.125rem;
margin-bottom: 0;
text-align: left;
color: #666;
font-size: 0.85em;
min-height: 1.2em;
line-height: 1.2em;
}
.submit-button {
flex: 1;
padding: 0.75rem;
background: #3498db;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.condition-form-submit-button {
width: 100%;
flex: none;
}
.submit-button:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-button {
flex: 1;
padding: 0.75rem;
background: #6b7280;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button:hover {
background: #4b5563;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 0.375rem;
padding: 0.75rem;
margin-bottom: 1rem;
}
/* Link input with pull button */
.link-input-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.link-input-wrapper .form-input {
flex: 1;
}
.pull-metadata-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #3498db;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.pull-metadata-button:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
.pull-metadata-button:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
}
.pull-metadata-button svg {
width: 20px;
height: 20px;
}
.mini-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Date Selector Styles (аналогично task-postpone-input-group) */
.date-selector-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
position: relative;
}
.date-selector-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.date-selector-display-date {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
background: white;
cursor: pointer;
transition: all 0.2s;
color: #1f2937;
user-select: none;
}
.date-selector-display-date:hover {
border-color: #3498db;
background: #f9fafb;
}
.date-selector-display-date:active {
background: #f3f4f6;
}
.date-selector-clear-button {
padding: 0.5rem;
background: #e5e7eb;
color: #6b7280;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.date-selector-clear-button:hover {
background: #d1d5db;
color: #374151;
}
.date-selector-clear-button:active {
transform: scale(0.95);
}
/* Task Autocomplete Styles */
.task-autocomplete {
position: relative;
}
.task-autocomplete-row {
display: flex;
gap: 8px;
align-items: center;
}
.task-autocomplete-input-wrapper {
flex: 1;
position: relative;
}
.task-autocomplete-input {
width: 100%;
padding: 12px 36px 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
background: white;
}
.task-autocomplete-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.task-autocomplete-input::placeholder {
color: #9ca3af;
}
.task-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.task-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
/* Кнопка создания */
.create-task-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.create-task-button:hover {
background: #4338ca;
}
/* Dropdown список */
.task-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 52px; /* Учитываем ширину кнопки + gap */
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.task-autocomplete-empty {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 14px;
}
.task-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.task-autocomplete-item:last-child {
border-bottom: none;
}
.task-autocomplete-item:hover,
.task-autocomplete-item.highlighted {
background: #f3f4f6;
}
.task-autocomplete-item.selected {
background: #eef2ff;
color: #4f46e5;
font-weight: 500;
}
.task-autocomplete-item.selected.highlighted {
background: #e0e7ff;
}
/* Group Autocomplete */
.group-autocomplete {
position: relative;
}
.group-autocomplete-input-wrapper {
position: relative;
}
.group-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.group-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
.group-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.group-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.group-autocomplete-item:last-child {
border-bottom: none;
}
.group-autocomplete-item:hover,
.group-autocomplete-item.highlighted {
background: #f3f4f6;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
.word-list {
position: relative;
max-width: 42rem; /* max-w-2xl = 672px */
margin: 0 auto;
}
.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;
}
.word-list h2 {
color: #2c3e50;
}
.add-button {
background-color: transparent;
color: #3498db;
border: 2px solid #3498db;
padding: 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 1.1rem;
font-weight: 500;
transition: all 0.2s ease;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
box-sizing: border-box;
margin: 0 0 1rem 0;
}
.add-button:hover {
background-color: #3498db;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.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;
}
.words-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.word-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
cursor: pointer;
}
.word-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.word-content {
flex: 1;
}
.word-header {
margin-bottom: 0.75rem;
}
.word-name {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.word-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.word-description {
font-size: 0.9rem;
color: #7f8c8d;
font-style: italic;
}
.word-stats {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.stat-success {
color: #3498db;
}
.stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.stat-failure {
color: #e74c3c;
}
.dictionary-name-input-container {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding-right: 60px; /* Space for close button */
margin-top: 0.5rem;
}
.dictionary-name-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 2px solid #e0e0e0;
border-radius: 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
transition: all 0.2s;
font-family: inherit;
background-color: transparent;
}
.dictionary-name-input:focus {
outline: none;
border-bottom-color: #3498db;
border-bottom-width: 3px;
}
.dictionary-name-input::placeholder {
color: #95a5a6;
font-weight: 400;
}
.dictionary-name-save-button {
background-color: #27ae60;
color: white;
border: none;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(39, 174, 96, 0.25);
font-weight: bold;
}
.dictionary-name-save-button:hover:not(:disabled) {
background-color: #229954;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
}
.dictionary-name-save-button:active:not(:disabled) {
transform: translateY(0);
}
.dictionary-name-save-button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
.menu-button {
position: fixed;
top: 1rem;
right: 4rem;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.5rem;
color: #2c3e50;
font-weight: bold;
transition: all 0.2s;
z-index: 1500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 0;
line-height: 1;
}
.menu-button:hover {
background-color: #ffffff;
color: #3498db;
transform: scale(1.1);
}
.word-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;
}
.word-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;
}
}
.word-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.word-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.word-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.word-modal-reset,
.word-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;
}
.word-modal-reset {
background-color: #f39c12;
color: white;
}
.word-modal-reset:hover {
background-color: #e67e22;
transform: translateY(-1px);
}
.word-modal-delete {
background-color: #e74c3c;
color: white;
}
.word-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './WordList.css'
const API_URL = '/api'
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [words, setWords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [dictionary, setDictionary] = useState(null)
const [dictionaryName, setDictionaryName] = useState('')
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
const [selectedWord, setSelectedWord] = useState(null)
// Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null)
// isNewDict is computed from currentDictionaryId: new dictionary if currentDictionaryId == null
const isNewDict = currentDictionaryId == null
// Helper function to check if dictionary exists and is not new
const hasValidDictionary = (dictId) => {
return dictId !== undefined && dictId !== null
}
useEffect(() => {
// Normalize undefined to null: if dictionaryId is undefined, treat it as null (new dictionary)
const normalizedDictionaryId = dictionaryId ?? null
setCurrentDictionaryId(normalizedDictionaryId)
if (normalizedDictionaryId == null) {
setLoading(false)
setDictionary(null)
setDictionaryName('')
setOriginalDictionaryName('')
setWords([])
} else if (hasValidDictionary(normalizedDictionaryId)) {
fetchDictionary(normalizedDictionaryId)
fetchWords(normalizedDictionaryId)
} else {
setLoading(false)
setWords([])
}
}, [dictionaryId, refreshTrigger])
const fetchDictionary = async (dictId) => {
try {
const response = await authFetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const dictionaries = await response.json()
const dict = dictionaries.find(d => d.id === dictId)
if (dict) {
setDictionary(dict)
setDictionaryName(dict.name)
setOriginalDictionaryName(dict.name)
}
} catch (err) {
console.error('Error fetching dictionary:', err)
}
}
const fetchWords = async (dictId) => {
if (!hasValidDictionary(dictId)) {
setWords([])
setLoading(false)
return
}
await fetchWordsForDictionary(dictId)
}
const fetchWordsForDictionary = async (dictId) => {
try {
setLoading(true)
const url = `${API_URL}/words?dictionary_id=${dictId}`
const response = await authFetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
const data = await response.json()
setWords(Array.isArray(data) ? data : [])
setError('')
} catch (err) {
setError(err.message)
setWords([])
} finally {
setLoading(false)
}
}
const handleWordClick = (word) => {
setSelectedWord(word)
}
const handleDeleteWord = async () => {
if (!selectedWord) return
if (!window.confirm('Вы уверены, что хотите удалить это слово?')) {
return
}
try {
const response = await authFetch(`${API_URL}/words/${selectedWord.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении слова')
}
// Remove word from local state
setWords(words.filter(word => word.id !== selectedWord.id))
setSelectedWord(null)
} catch (err) {
setError(err.message)
}
}
const handleResetProgress = async () => {
if (!selectedWord) return
if (!window.confirm('Вы уверены, что хотите сбросить прогресс этого слова?')) {
return
}
try {
const response = await authFetch(`${API_URL}/words/${selectedWord.id}/reset-progress`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при сбросе прогресса')
}
// Update word in local state - reset progress fields
setWords(words.map(word =>
word.id === selectedWord.id
? { ...word, success: 0, failure: 0, last_success_at: null, last_failure_at: null }
: word
))
setSelectedWord(null)
} catch (err) {
setError(err.message)
}
}
const handleNameChange = (e) => {
setDictionaryName(e.target.value)
}
const handleNameSave = async () => {
if (!dictionaryName.trim()) {
return
}
setIsSavingName(true)
try {
if (!hasValidDictionary(currentDictionaryId)) {
// Create new dictionary
const response = await authFetch(`${API_URL}/dictionaries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: dictionaryName.trim() }),
})
if (!response.ok) {
throw new Error('Ошибка при создании словаря')
}
const newDict = await response.json()
const newDictionaryId = newDict.id
// Update local state
setOriginalDictionaryName(newDict.name)
setDictionaryName(newDict.name)
setDictionary(newDict)
setCurrentDictionaryId(newDictionaryId)
// Reinitialize screen: fetch dictionary info and words for the new dictionary
await fetchDictionary(newDictionaryId)
await fetchWordsForDictionary(newDictionaryId)
// Update navigation to use the new dictionary ID and name
onNavigate?.('words', { dictionaryId: newDictionaryId, dictionaryName: newDict.name })
} else if (hasValidDictionary(currentDictionaryId)) {
// Update existing dictionary (rename)
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: dictionaryName.trim() }),
})
if (!response.ok) {
throw new Error('Ошибка при обновлении словаря')
}
setOriginalDictionaryName(dictionaryName.trim())
if (dictionary) {
setDictionary({ ...dictionary, name: dictionaryName.trim() })
}
}
} catch (err) {
setError(err.message)
} finally {
setIsSavingName(false)
}
}
// Show save button only if name is not empty and has changed
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
if (error && !loading) {
return (
<div className="word-list">
<LoadingError onRetry={() => {
if (hasValidDictionary(currentDictionaryId)) {
fetchWordsForDictionary(currentDictionaryId)
}
}} />
</div>
)
}
return (
<div className="word-list">
<button
onClick={() => window.history.back()}
className="close-x-button"
title="Закрыть"
>
</button>
{loading && (
<div className="fixed inset-0 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>
)}
{/* Dictionary name input */}
<div className="dictionary-name-input-container">
<input
type="text"
className="dictionary-name-input"
value={dictionaryName}
onChange={handleNameChange}
placeholder="Введите название словаря"
/>
{showSaveButton && (
<button
className="dictionary-name-save-button"
onClick={handleNameSave}
disabled={isSavingName}
title="Сохранить название"
>
</button>
)}
</div>
{/* Show add button and words list only if dictionaryId exists and is not new */}
{hasValidDictionary(currentDictionaryId) && (
<>
{(!words || words.length === 0) ? (
<>
<div className="empty-state">
<p>Слов пока нет. Добавьте слова через экран "Добавить слова".</p>
</div>
</>
) : (
<>
<div className="words-grid">
{words.map((word) => (
<div
key={word.id}
className="word-card"
onClick={() => handleWordClick(word)}
>
<div className="word-content">
<div className="word-header">
<h3 className="word-name">{word.name}</h3>
</div>
<div className="word-translation">{word.translation}</div>
{word.description && (
<div className="word-description">{word.description}</div>
)}
</div>
<div className="word-stats">
<span className="stat-success">{word.success || 0}</span>
<span className="stat-separator"> | </span>
<span className="stat-failure">{word.failure || 0}</span>
</div>
</div>
))}
</div>
</>
)}
</>
)}
{/* Модальное окно для действий со словом */}
{selectedWord && (
<div className="word-modal-overlay" onClick={() => setSelectedWord(null)}>
<div className="word-modal" onClick={(e) => e.stopPropagation()}>
<div className="word-modal-header">
<h3>{selectedWord.name}</h3>
</div>
<div className="word-modal-actions">
<button className="word-modal-reset" onClick={handleResetProgress}>
Сбросить
</button>
<button className="word-modal-delete" onClick={handleDeleteWord}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default WordList

View File

@@ -0,0 +1,353 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
const AuthContext = createContext(null)
const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const USER_KEY = 'user'
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Ref для синхронизации параллельных refresh-запросов
const refreshPromiseRef = useRef(null)
const logout = useCallback(async () => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
} catch (err) {
console.error('Logout error:', err)
}
}
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
setUser(null)
}, [])
// Внутренняя функция для выполнения refresh
const doRefreshToken = useCallback(async () => {
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY)
if (!refresh) {
console.warn('[Auth] No refresh token in localStorage')
return { success: false, isNetworkError: false }
}
console.log('[Auth] Attempting refresh with token:', refresh.substring(0, 10) + '...')
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout (increased)
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refresh }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
// Логируем тело ответа для диагностики
let errorBody = ''
try {
errorBody = await response.text()
} catch (e) {
errorBody = 'Could not read error body'
}
console.error('[Auth] Refresh failed:', response.status, errorBody)
// 401 means invalid token (real auth error)
// Other errors might be temporary (503, 502, etc.)
const isAuthError = response.status === 401
return { success: false, isNetworkError: !isAuthError }
}
const data = await response.json()
// Проверяем что токены действительно пришли
if (!data.access_token || !data.refresh_token) {
console.error('[Auth] Refresh response missing tokens:', Object.keys(data))
return { success: false, isNetworkError: false }
}
console.log('[Auth] Refresh successful, saving new tokens')
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return { success: true, isNetworkError: false }
} catch (err) {
console.error('[Auth] Refresh error:', err.name, err.message)
// Network errors should be treated as temporary
if (err.name === 'AbortError' ||
(err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch')))) {
console.warn('[Auth] Refresh token network error, keeping session')
return { success: false, isNetworkError: true }
}
// Other errors might be auth related
return { success: false, isNetworkError: false }
}
}, [])
// Синхронизированная функция refresh - предотвращает race condition
// Если refresh уже выполняется, все вызовы ждут его завершения
const refreshToken = useCallback(async () => {
// Если refresh уже выполняется, ждём его завершения
if (refreshPromiseRef.current) {
console.log('[Auth] Refresh already in progress, waiting...')
return refreshPromiseRef.current
}
// Создаём promise для refresh и сохраняем его
console.log('[Auth] Starting token refresh...')
refreshPromiseRef.current = doRefreshToken().finally(() => {
// Очищаем ref после завершения (успешного или нет)
refreshPromiseRef.current = null
})
return refreshPromiseRef.current
}, [doRefreshToken])
// Initialize from localStorage
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem(TOKEN_KEY)
const savedUser = localStorage.getItem(USER_KEY)
console.log('[Auth] Initializing auth, token exists:', !!token, 'user exists:', !!savedUser)
if (token && savedUser) {
try {
const parsedUser = JSON.parse(savedUser)
setUser(parsedUser) // Set user immediately from localStorage
console.log('[Auth] User restored from localStorage:', parsedUser.email)
// Verify token is still valid with timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
signal: controller.signal
})
clearTimeout(timeoutId)
if (response.ok) {
const data = await response.json()
setUser(data.user)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
console.log('[Auth] Token verified successfully')
} else if (response.status === 401) {
// Try to refresh token
console.log('[Auth] Access token expired, attempting refresh...')
const result = await refreshToken()
if (!result.success && !result.isNetworkError) {
// Only logout on real auth errors, not network errors
console.warn('[Auth] Refresh failed with auth error, logging out')
logout()
} else if (!result.success) {
// Network error - keep session, backend might be starting up
console.warn('[Auth] Token refresh failed due to network error, keeping session. User remains logged in.')
// User is already set from localStorage above, so they stay logged in
} else {
console.log('[Auth] Token refreshed successfully')
}
} else {
// For other errors (like 503, 502, network errors), don't clear auth
// Just log the error and keep the user logged in
console.warn('[Auth] Auth check failed with status:', response.status, 'but keeping session. User remains logged in.')
// User is already set from localStorage above, so they stay logged in
}
} catch (err) {
// Network errors (e.g., backend not ready) should not clear auth
// Only clear if it's a real auth error
if (err.name === 'AbortError') {
// Timeout - backend might be starting up, keep auth state
console.warn('[Auth] Auth check timeout, backend might be starting up. Keeping session. User remains logged in.')
// User is already set from localStorage above, so they stay logged in
} else if (err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch'))) {
// Network error - backend might be starting up, keep auth state
console.warn('[Auth] Network error during auth check, keeping session:', err.message, 'User remains logged in.')
// User is already set from localStorage above, so they stay logged in
} else {
// Other errors - might be auth related
console.error('[Auth] Auth init error:', err)
// Don't automatically logout on unknown errors
// User is already set from localStorage above, so they stay logged in
}
}
} else {
console.log('[Auth] No saved auth data found')
}
setLoading(false)
}
initAuth()
}, [refreshToken, logout])
const login = useCallback(async (email, password) => {
setError(null)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Ошибка входа')
}
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return true
} catch (err) {
setError(err.message)
return false
}
}, [])
const register = useCallback(async (email, password, name) => {
setError(null)
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password, name: name || undefined })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Ошибка регистрации')
}
localStorage.setItem(TOKEN_KEY, data.access_token)
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
setUser(data.user)
return true
} catch (err) {
setError(err.message)
return false
}
}, [])
const getToken = useCallback(() => {
return localStorage.getItem(TOKEN_KEY)
}, [])
// Fetch wrapper that handles auth
const authFetch = useCallback(async (url, options = {}) => {
const token = localStorage.getItem(TOKEN_KEY)
// Не устанавливаем Content-Type для FormData - браузер сделает это автоматически
const isFormData = options.body instanceof FormData
const headers = {}
if (!isFormData && !options.headers?.['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
// Добавляем пользовательские заголовки
if (options.headers) {
Object.assign(headers, options.headers)
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
try {
let response = await fetch(url, { ...options, headers })
// If 401, try to refresh token and retry
if (response.status === 401) {
console.log('[Auth] Got 401 for', url, '- attempting token refresh')
const result = await refreshToken()
if (result.success) {
console.log('[Auth] Token refreshed, retrying request to', url)
const newToken = localStorage.getItem(TOKEN_KEY)
headers['Authorization'] = `Bearer ${newToken}`
response = await fetch(url, { ...options, headers })
console.log('[Auth] Retry response status:', response.status)
} else if (!result.isNetworkError) {
// Only logout if refresh failed due to auth error (not network error)
console.warn('[Auth] Refresh failed with auth error, logging out')
logout()
} else {
console.warn('[Auth] Refresh failed with network error, keeping session but request failed')
}
// If network error, don't logout - let the caller handle the 401
}
return response
} catch (err) {
// Network errors should not trigger logout
// Let the caller handle the error
console.error('[Auth] Fetch error for', url, ':', err.message)
throw err
}
}, [refreshToken, logout])
const value = {
user,
loading,
error,
login,
register,
logout,
getToken,
authFetch,
isAuthenticated: !!user
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export default AuthContext

View File

@@ -0,0 +1,16 @@
import React, { useState } from 'react'
import LoginForm from './LoginForm'
import RegisterForm from './RegisterForm'
function AuthScreen() {
const [mode, setMode] = useState('login') // 'login' or 'register'
if (mode === 'register') {
return <RegisterForm onSwitchToLogin={() => setMode('login')} />
}
return <LoginForm onSwitchToRegister={() => setMode('register')} />
}
export default AuthScreen

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react'
import { useAuth } from './AuthContext'
function LoginForm({ onSwitchToRegister }) {
const { login, error } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [localError, setLocalError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setLocalError('')
if (!email.trim()) {
setLocalError('Введите email')
return
}
if (!password) {
setLocalError('Введите пароль')
return
}
setLoading(true)
const success = await login(email, password)
setLoading(false)
if (!success) {
setLocalError(error || 'Ошибка входа')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
<p className="text-gray-300">Войдите в свой аккаунт</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="••••••••"
autoComplete="current-password"
/>
</div>
{(localError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
{localError || error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Вход...
</span>
) : 'Войти'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-400">
Нет аккаунта?{' '}
<button
onClick={onSwitchToRegister}
className="text-purple-400 hover:text-purple-300 font-medium transition"
>
Зарегистрироваться
</button>
</p>
</div>
</div>
</div>
</div>
)
}
export default LoginForm

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react'
import { useAuth } from './AuthContext'
function RegisterForm({ onSwitchToLogin }) {
const { register, error } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [name, setName] = useState('')
const [loading, setLoading] = useState(false)
const [localError, setLocalError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setLocalError('')
if (!email.trim()) {
setLocalError('Введите email')
return
}
if (!password) {
setLocalError('Введите пароль')
return
}
if (password.length < 6) {
setLocalError('Пароль должен быть не менее 6 символов')
return
}
if (password !== confirmPassword) {
setLocalError('Пароли не совпадают')
return
}
setLoading(true)
const success = await register(email, password, name || undefined)
setLoading(false)
if (!success) {
setLocalError(error || 'Ошибка регистрации')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
<p className="text-gray-300">Создайте аккаунт</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Имя <span className="text-gray-500">(необязательно)</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Ваше имя"
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Минимум 6 символов"
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
Подтвердите пароль
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
placeholder="Повторите пароль"
autoComplete="new-password"
/>
</div>
{(localError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
{localError || error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Регистрация...
</span>
) : 'Зарегистрироваться'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-400">
Уже есть аккаунт?{' '}
<button
onClick={onSwitchToLogin}
className="text-purple-400 hover:text-purple-300 font-medium transition"
>
Войти
</button>
</p>
</div>
</div>
</div>
</div>
)
}
export default RegisterForm

View File

@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
height: 100%;
height: 100dvh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-overflow-scrolling: touch;
}
#root {
height: 100vh;
height: 100dvh; /* Dynamic viewport height для мобильных устройств */
overflow: hidden;
background: #f3f4f6;
background-attachment: fixed;
display: flex;
flex-direction: column;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,146 @@
// Утилиты для работы с проектами - обеспечивают единую сортировку и цвета
// Палитра из 30 контрастных цветов для проектов (HEX формат)
// Должна быть синхронизирована с backend (main.go)
export const PROJECT_COLORS_PALETTE = [
'#EF4444', // Красный
'#F97316', // Оранжевый
'#F59E0B', // Янтарный
'#EAB308', // Желтый
'#84CC16', // Лайм
'#22C55E', // Зеленый
'#10B981', // Изумрудный
'#14B8A6', // Бирюзовый
'#06B6D4', // Голубой
'#0EA5E9', // Небесный
'#3B82F6', // Синий
'#6366F1', // Индиго
'#8B5CF6', // Фиолетовый
'#A855F7', // Пурпурный
'#D946EF', // Фуксия
'#EC4899', // Розовый
'#F43F5E', // Розово-красный
'#DC2626', // Темно-красный
'#EA580C', // Темно-оранжевый
'#CA8A04', // Темно-желтый
'#65A30D', // Темно-лайм
'#16A34A', // Темно-зеленый
'#059669', // Темно-изумрудный
'#0D9488', // Темно-бирюзовый
'#0891B2', // Темно-голубой
'#0284C7', // Темно-небесный
'#2563EB', // Темно-синий
'#4F46E5', // Темно-индиго
'#7C3AED', // Темно-фиолетовый
'#9333EA', // Темно-пурпурный
]
// Функция для генерации цвета проекта на основе его индекса в отсортированном списке
export function getProjectColorByIndex(index) {
const hue = (index * 137.508) % 360 // Золотой угол для равномерного распределения цветов
return `hsl(${hue}, 70%, 50%)`
}
/**
* Получает отсортированный список всех проектов для определения цветов
* Сортировка: по алфавиту (стабильный порядок для цветов)
*
* @param {Array} allProjectsData - данные полной статистики (массив объектов с project_name)
* @param {Array} currentWeekData - данные текущей недели (массив объектов с project_name)
* @returns {Array} отсортированный массив названий проектов
*/
export function getAllProjectsSorted(allProjectsData, currentWeekData = null) {
const projectsSet = new Set()
// Собираем проекты из полной статистики (приоритетный источник)
if (allProjectsData && allProjectsData.length > 0) {
allProjectsData.forEach(item => {
projectsSet.add(item.project_name)
})
}
// Если данных полной статистики нет, используем проекты из текущей недели
if (projectsSet.size === 0 && currentWeekData) {
const projects = Array.isArray(currentWeekData)
? currentWeekData
: (currentWeekData?.projects || [])
projects.forEach(item => {
projectsSet.add(item.project_name)
})
}
return Array.from(projectsSet).sort()
}
/**
* Получает цвет проекта на основе его названия или цвета из БД
*
* @param {string} projectName - название проекта
* @param {Array} allProjectsSorted - отсортированный список всех проектов
* @param {string|null} projectColorFromDB - цвет проекта из базы данных (HEX формат)
* @returns {string} цвет в формате HEX или HSL (fallback)
*/
export function getProjectColor(projectName, allProjectsSorted, projectColorFromDB = null) {
// Если передан цвет из БД и он не пустой - использовать его
if (projectColorFromDB && projectColorFromDB.trim() !== '') {
return projectColorFromDB
}
// Иначе использовать вычисляемый цвет (текущая логика) - это fallback для обратной совместимости
const projectIndex = allProjectsSorted.indexOf(projectName)
return projectIndex >= 0 ? getProjectColorByIndex(projectIndex) : '#9CA3AF'
}
/**
* Нормализует значение priority для сортировки
*/
function normalizePriority(value) {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
/**
* Сортирует проекты так же, как на экране списка проектов:
* сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
*
* @param {Array} projectNames - массив названий проектов для сортировки
* @param {Array} currentWeekData - данные текущей недели с информацией о priority и min_goal_score
* @returns {Array} отсортированный массив названий проектов
*/
export function sortProjectsLikeCurrentWeek(projectNames, currentWeekData) {
if (!currentWeekData || projectNames.length === 0) {
return projectNames
}
// Получаем данные проектов из currentWeekData
const projectsData = currentWeekData?.projects || (Array.isArray(currentWeekData) ? currentWeekData : [])
// Создаем Map для быстрого доступа к данным проекта
const projectDataMap = new Map()
projectsData.forEach(project => {
if (project.project_name) {
projectDataMap.set(project.project_name, {
priority: project.priority,
min_goal_score: parseFloat(project.min_goal_score) || 0
})
}
})
// Сортируем проекты
return [...projectNames].sort((a, b) => {
const dataA = projectDataMap.get(a) || { priority: null, min_goal_score: 0 }
const dataB = projectDataMap.get(b) || { priority: null, min_goal_score: 0 }
const priorityA = normalizePriority(dataA.priority)
const priorityB = normalizePriority(dataB.priority)
if (priorityA !== priorityB) {
return priorityA - priorityB
}
return dataB.min_goal_score - dataA.min_goal_score
})
}