231
play-life-web/src/components/AddWords.css
Normal file
231
play-life-web/src/components/AddWords.css
Normal 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;
|
||||
}
|
||||
|
||||
325
play-life-web/src/components/AddWords.jsx
Normal file
325
play-life-web/src/components/AddWords.jsx
Normal 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
|
||||
|
||||
132
play-life-web/src/components/BoardForm.css
Normal file
132
play-life-web/src/components/BoardForm.css
Normal 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;
|
||||
}
|
||||
286
play-life-web/src/components/BoardForm.jsx
Normal file
286
play-life-web/src/components/BoardForm.jsx
Normal 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
|
||||
|
||||
199
play-life-web/src/components/BoardJoinPreview.css
Normal file
199
play-life-web/src/components/BoardJoinPreview.css
Normal 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;
|
||||
}
|
||||
|
||||
156
play-life-web/src/components/BoardJoinPreview.jsx
Normal file
156
play-life-web/src/components/BoardJoinPreview.jsx
Normal 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
|
||||
|
||||
132
play-life-web/src/components/BoardMembers.css
Normal file
132
play-life-web/src/components/BoardMembers.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
113
play-life-web/src/components/BoardMembers.jsx
Normal file
113
play-life-web/src/components/BoardMembers.jsx
Normal 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
|
||||
|
||||
261
play-life-web/src/components/BoardSelector.css
Normal file
261
play-life-web/src/components/BoardSelector.css
Normal 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;
|
||||
}
|
||||
121
play-life-web/src/components/BoardSelector.jsx
Normal file
121
play-life-web/src/components/BoardSelector.jsx
Normal 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
|
||||
63
play-life-web/src/components/Buttons.css
Normal file
63
play-life-web/src/components/Buttons.css
Normal 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;
|
||||
}
|
||||
63
play-life-web/src/components/ColorPickerModal.jsx
Normal file
63
play-life-web/src/components/ColorPickerModal.jsx
Normal 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
|
||||
176
play-life-web/src/components/CurrentWeek.css
Normal file
176
play-life-web/src/components/CurrentWeek.css
Normal 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;
|
||||
}
|
||||
715
play-life-web/src/components/CurrentWeek.jsx
Normal file
715
play-life-web/src/components/CurrentWeek.jsx
Normal 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
|
||||
|
||||
29
play-life-web/src/components/DeleteButton.jsx
Normal file
29
play-life-web/src/components/DeleteButton.jsx
Normal 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
|
||||
224
play-life-web/src/components/DictionaryList.css
Normal file
224
play-life-web/src/components/DictionaryList.css
Normal 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);
|
||||
}
|
||||
|
||||
155
play-life-web/src/components/DictionaryList.jsx
Normal file
155
play-life-web/src/components/DictionaryList.jsx
Normal 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
|
||||
|
||||
528
play-life-web/src/components/FitbitIntegration.jsx
Normal file
528
play-life-web/src/components/FitbitIntegration.jsx
Normal 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
|
||||
202
play-life-web/src/components/FullStatistics.jsx
Normal file
202
play-life-web/src/components/FullStatistics.jsx
Normal 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
|
||||
|
||||
25
play-life-web/src/components/Integrations.css
Normal file
25
play-life-web/src/components/Integrations.css
Normal 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;
|
||||
}
|
||||
|
||||
54
play-life-web/src/components/LoadingError.css
Normal file
54
play-life-web/src/components/LoadingError.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.loading-error-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 80px; /* Отступ для нижнего бара */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Учитываем safe-area для мобильных устройств */
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.loading-error-container {
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.loading-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-error-text {
|
||||
color: #374151;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-error-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(to right, #4f46e5, #9333ea);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.loading-error-button:hover {
|
||||
background: linear-gradient(to right, #4338ca, #7e22ce);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.loading-error-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
23
play-life-web/src/components/LoadingError.jsx
Normal file
23
play-life-web/src/components/LoadingError.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import './LoadingError.css'
|
||||
|
||||
function LoadingError({ onRetry }) {
|
||||
return (
|
||||
<div className="loading-error-container">
|
||||
<div className="loading-error-content">
|
||||
<div className="loading-error-text">Ошибка, повторите позже</div>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="loading-error-button"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingError
|
||||
|
||||
59
play-life-web/src/components/PWAUpdatePrompt.jsx
Normal file
59
play-life-web/src/components/PWAUpdatePrompt.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
196
play-life-web/src/components/Profile.jsx
Normal file
196
play-life-web/src/components/Profile.jsx
Normal 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
|
||||
|
||||
1105
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
1105
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
File diff suppressed because it is too large
Load Diff
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal 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
|
||||
|
||||
20
play-life-web/src/components/SubmitButton.jsx
Normal file
20
play-life-web/src/components/SubmitButton.jsx
Normal 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
|
||||
429
play-life-web/src/components/TaskDetail.css
Normal file
429
play-life-web/src/components/TaskDetail.css
Normal 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;
|
||||
}
|
||||
|
||||
319
play-life-web/src/components/TaskDetail.css.bak
Normal file
319
play-life-web/src/components/TaskDetail.css.bak
Normal 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;
|
||||
}
|
||||
|
||||
933
play-life-web/src/components/TaskDetail.jsx
Normal file
933
play-life-web/src/components/TaskDetail.jsx
Normal 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
|
||||
|
||||
610
play-life-web/src/components/TaskForm.css
Normal file
610
play-life-web/src/components/TaskForm.css
Normal 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;
|
||||
}
|
||||
1432
play-life-web/src/components/TaskForm.jsx
Normal file
1432
play-life-web/src/components/TaskForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
876
play-life-web/src/components/TaskList.css
Normal file
876
play-life-web/src/components/TaskList.css
Normal 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);
|
||||
}
|
||||
1171
play-life-web/src/components/TaskList.jsx
Normal file
1171
play-life-web/src/components/TaskList.jsx
Normal file
File diff suppressed because it is too large
Load Diff
140
play-life-web/src/components/TelegramIntegration.jsx
Normal file
140
play-life-web/src/components/TelegramIntegration.jsx
Normal 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
|
||||
466
play-life-web/src/components/TestWords.css
Normal file
466
play-life-web/src/components/TestWords.css
Normal 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);
|
||||
}
|
||||
|
||||
772
play-life-web/src/components/TestWords.jsx
Normal file
772
play-life-web/src/components/TestWords.jsx
Normal 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
|
||||
47
play-life-web/src/components/Toast.css
Normal file
47
play-life-web/src/components/Toast.css
Normal 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;
|
||||
}
|
||||
|
||||
30
play-life-web/src/components/Toast.jsx
Normal file
30
play-life-web/src/components/Toast.jsx
Normal 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
|
||||
|
||||
270
play-life-web/src/components/TodayEntriesList.jsx
Normal file
270
play-life-web/src/components/TodayEntriesList.jsx
Normal 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
|
||||
210
play-life-web/src/components/TodoistIntegration.jsx
Normal file
210
play-life-web/src/components/TodoistIntegration.jsx
Normal 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
|
||||
436
play-life-web/src/components/Tracking.css
Normal file
436
play-life-web/src/components/Tracking.css
Normal 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;
|
||||
}
|
||||
193
play-life-web/src/components/Tracking.jsx
Normal file
193
play-life-web/src/components/Tracking.jsx
Normal 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
|
||||
158
play-life-web/src/components/TrackingAccess.jsx
Normal file
158
play-life-web/src/components/TrackingAccess.jsx
Normal 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
|
||||
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal file
115
play-life-web/src/components/TrackingInviteAccept.jsx
Normal 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
|
||||
320
play-life-web/src/components/WeekProgressChart.jsx
Normal file
320
play-life-web/src/components/WeekProgressChart.jsx
Normal 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
|
||||
|
||||
447
play-life-web/src/components/Wishlist.css
Normal file
447
play-life-web/src/components/Wishlist.css
Normal 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);
|
||||
}
|
||||
|
||||
789
play-life-web/src/components/Wishlist.jsx
Normal file
789
play-life-web/src/components/Wishlist.jsx
Normal 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
|
||||
458
play-life-web/src/components/WishlistDetail.css
Normal file
458
play-life-web/src/components/WishlistDetail.css
Normal 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;
|
||||
}
|
||||
|
||||
755
play-life-web/src/components/WishlistDetail.jsx
Normal file
755
play-life-web/src/components/WishlistDetail.jsx
Normal 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
|
||||
|
||||
713
play-life-web/src/components/WishlistForm.css
Normal file
713
play-life-web/src/components/WishlistForm.css
Normal 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;
|
||||
}
|
||||
1493
play-life-web/src/components/WishlistForm.jsx
Normal file
1493
play-life-web/src/components/WishlistForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
344
play-life-web/src/components/WordList.css
Normal file
344
play-life-web/src/components/WordList.css
Normal 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);
|
||||
}
|
||||
335
play-life-web/src/components/WordList.jsx
Normal file
335
play-life-web/src/components/WordList.jsx
Normal 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
|
||||
|
||||
353
play-life-web/src/components/auth/AuthContext.jsx
Normal file
353
play-life-web/src/components/auth/AuthContext.jsx
Normal 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
|
||||
|
||||
16
play-life-web/src/components/auth/AuthScreen.jsx
Normal file
16
play-life-web/src/components/auth/AuthScreen.jsx
Normal 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
|
||||
|
||||
112
play-life-web/src/components/auth/LoginForm.jsx
Normal file
112
play-life-web/src/components/auth/LoginForm.jsx
Normal 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
|
||||
|
||||
150
play-life-web/src/components/auth/RegisterForm.jsx
Normal file
150
play-life-web/src/components/auth/RegisterForm.jsx
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user