Files
play-life/play-life-web/src/components/TodayEntriesList.jsx
2026-02-08 17:01:36 +03:00

271 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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