nakama/frontend/src/components/CommentsModal.jsx

373 lines
14 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 { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X, Send, Trash2, Edit2 } from 'lucide-react'
import { commentPost, getPosts, deleteComment, editComment } from '../utils/api'
import { hapticFeedback, showConfirm } from '../utils/telegram'
import { decodeHtmlEntities } from '../utils/htmlEntities'
import './CommentsModal.css'
export default function CommentsModal({ post, onClose, onUpdate, currentUser }) {
// ВСЕ хуки должны вызываться всегда, до любых условных возвратов
const [comment, setComment] = useState('')
const [loading, setLoading] = useState(false)
const [comments, setComments] = useState([])
const [fullPost, setFullPost] = useState(null)
const [loadingPost, setLoadingPost] = useState(false)
const [editingCommentId, setEditingCommentId] = useState(null)
const [editText, setEditText] = useState('')
// Загрузить полные данные поста с комментариями
// ВАЖНО: useEffect всегда вызывается, даже если post отсутствует
useEffect(() => {
// Если пост не передан, очищаем состояние и выходим
if (!post || !post._id) {
setFullPost(null)
setComments([])
setLoadingPost(false)
return
}
// Сначала установим переданные данные
setFullPost(post)
const initialComments = (post.comments || []).filter(c => {
return c && c.author && (typeof c.author === 'object')
})
setComments(initialComments)
// Затем загрузим полные данные для обновления
let cancelled = false
const loadFullPost = async () => {
try {
setLoadingPost(true)
// Загрузить посты с фильтром по автору поста для оптимизации
const authorId = post?.author?._id || post?.author
const response = authorId
? await getPosts({ userId: authorId, limit: 100 })
: await getPosts({ limit: 200 })
// Проверяем, что запрос не был отменен
if (cancelled) return
const foundPost = response.posts?.find(p => p._id === post._id)
if (foundPost) {
// Проверяем, что комментарии populate'ены с авторами
const commentsWithAuthors = (foundPost.comments || []).filter(c => {
return c && c.author && (typeof c.author === 'object')
})
setComments(commentsWithAuthors)
setFullPost(foundPost)
}
} catch (error) {
console.error('[CommentsModal] Ошибка загрузки поста:', error)
// Оставляем переданные данные
} finally {
if (!cancelled) {
setLoadingPost(false)
}
}
}
loadFullPost()
// Cleanup функция для отмены запроса при размонтировании
return () => {
cancelled = true
}
}, [post?._id]) // Используем просто post?._id без || null
// Проверка на существование поста ПОСЛЕ хуков
const displayPost = fullPost || post
const hasValidPost = post && post._id && displayPost && displayPost.author
const handleSubmit = async () => {
if (!comment.trim() || loading || !post || !post._id) return
try {
setLoading(true)
hapticFeedback('light')
const result = await commentPost(post._id, comment)
console.log('[CommentsModal] Результат добавления комментария:', result)
if (result && result.comments && Array.isArray(result.comments)) {
// Фильтруем комментарии с авторами (проверяем, что author - объект)
const commentsWithAuthors = result.comments.filter(c => {
return c && c.author && (typeof c.author === 'object')
})
console.log('[CommentsModal] Отфильтрованные комментарии:', commentsWithAuthors.length, 'из', result.comments.length)
setComments(commentsWithAuthors)
// Обновить полный пост
if (fullPost) {
setFullPost({ ...fullPost, comments: commentsWithAuthors })
}
setComment('')
hapticFeedback('success')
if (onUpdate) {
onUpdate()
}
} else {
console.error('[CommentsModal] Неожиданный формат ответа:', result)
hapticFeedback('error')
}
} catch (error) {
console.error('[CommentsModal] Ошибка добавления комментария:', error)
hapticFeedback('error')
} finally {
setLoading(false)
}
}
const formatDate = (date) => {
if (!date) return 'только что'
const d = new Date(date)
const now = new Date()
const diff = Math.floor((now - d) / 1000) // секунды
if (diff < 60) return 'только что'
if (diff < 3600) return `${Math.floor(diff / 60)} мин`
if (diff < 86400) return `${Math.floor(diff / 3600)} ч`
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
const handleOverlayClick = (e) => {
// Закрывать только при клике на overlay, не на контент
if (e.target === e.currentTarget) {
onClose()
}
}
const handleDeleteComment = async (commentId) => {
if (!post || !post._id) return
const confirmed = await showConfirm('Удалить этот комментарий?')
if (!confirmed) return
try {
hapticFeedback('light')
const result = await deleteComment(post._id, commentId)
if (result && result.comments && Array.isArray(result.comments)) {
const commentsWithAuthors = result.comments.filter(c => {
return c && c.author && (typeof c.author === 'object')
})
setComments(commentsWithAuthors)
if (fullPost) {
setFullPost({ ...fullPost, comments: commentsWithAuthors })
}
hapticFeedback('success')
if (onUpdate) {
onUpdate()
}
}
} catch (error) {
console.error('[CommentsModal] Ошибка удаления комментария:', error)
hapticFeedback('error')
}
}
const handleStartEdit = (comment) => {
setEditingCommentId(comment._id)
setEditText(comment.content)
}
const handleCancelEdit = () => {
setEditingCommentId(null)
setEditText('')
}
const handleSaveEdit = async (commentId) => {
if (!post || !post._id || !editText.trim()) return
try {
hapticFeedback('light')
const result = await editComment(post._id, commentId, editText.trim())
if (result && result.comments && Array.isArray(result.comments)) {
const commentsWithAuthors = result.comments.filter(c => {
return c && c.author && (typeof c.author === 'object')
})
setComments(commentsWithAuthors)
if (fullPost) {
setFullPost({ ...fullPost, comments: commentsWithAuthors })
}
setEditingCommentId(null)
setEditText('')
hapticFeedback('success')
if (onUpdate) {
onUpdate()
}
}
} catch (error) {
console.error('[CommentsModal] Ошибка редактирования комментария:', error)
hapticFeedback('error')
}
}
const isCommentAuthor = (comment) => {
if (!currentUser || !comment.author) return false
const authorId = comment.author._id || comment.author
const userId = currentUser.id || currentUser._id
return authorId === userId
}
const isModerator = () => {
return currentUser && (currentUser.role === 'moderator' || currentUser.role === 'admin')
}
// ВСЕГДА рендерим createPortal, даже если пост не валиден
// Это критично для соблюдения правил хуков
return createPortal(
<div
className="comments-modal-overlay"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={handleOverlayClick}
style={{ display: hasValidPost ? 'flex' : 'none' }}
>
<div className="comments-modal" onClick={(e) => e.stopPropagation()}>
{/* Хедер */}
<div className="modal-header">
<button className="close-btn" onClick={onClose}>
<X size={24} />
</button>
<h2>Комментарии</h2>
<div style={{ width: 40 }} />
</div>
{/* Список комментариев */}
{hasValidPost && (
<div className="comments-list">
{loadingPost ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : comments.length === 0 ? (
<div className="empty-comments">
<p>Пока нет комментариев</p>
<span>Будьте первым!</span>
</div>
) : (
comments
.filter(c => {
// Фильтруем комментарии без автора или с неполным автором
return c && c.author && (typeof c.author === 'object') && c.content
})
.map((c, index) => {
// Используем _id если есть, иначе index
const commentId = c._id || c.id || `comment-${index}`
// Проверяем, что автор полностью загружен
if (!c.author || typeof c.author !== 'object') {
console.warn('[CommentsModal] Комментарий без автора:', c)
return null
}
const canEdit = isCommentAuthor(c) || isModerator()
const isEditing = editingCommentId === commentId
return (
<div key={commentId} className="comment-item fade-in">
<img
src={c.author?.photoUrl || '/default-avatar.png'}
alt={c.author?.username || c.author?.firstName || 'User'}
className="comment-avatar"
onError={(e) => { e.target.src = '/default-avatar.png' }}
/>
<div className="comment-content">
<div className="comment-header">
<span className="comment-author">
{c.author?.firstName || ''} {c.author?.lastName || ''}
{!c.author?.firstName && !c.author?.lastName && 'Пользователь'}
</span>
<span className="comment-time">{formatDate(c.createdAt)}</span>
</div>
{isEditing ? (
<div className="comment-edit-form">
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="comment-edit-input"
autoFocus
/>
<div className="comment-edit-actions">
<button
className="comment-edit-btn save"
onClick={() => handleSaveEdit(commentId)}
disabled={!editText.trim()}
>
Сохранить
</button>
<button
className="comment-edit-btn cancel"
onClick={handleCancelEdit}
>
Отмена
</button>
</div>
</div>
) : (
<p className="comment-text">{decodeHtmlEntities(c.content)}</p>
)}
</div>
{canEdit && !isEditing && (
<div className="comment-actions">
{isCommentAuthor(c) && (
<button
className="comment-action-btn"
onClick={() => handleStartEdit(c)}
title="Редактировать"
>
<Edit2 size={16} />
</button>
)}
<button
className="comment-action-btn delete"
onClick={() => handleDeleteComment(commentId)}
title="Удалить"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
)
})
.filter(Boolean) // Убираем null значения
)}
</div>
)}
{/* Форма добавления комментария */}
{hasValidPost && (
<div className="comment-form">
<input
type="text"
placeholder="Написать комментарий..."
value={comment}
onChange={e => setComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleSubmit()}
maxLength={500}
/>
<button
onClick={handleSubmit}
disabled={loading || !comment.trim()}
className="send-btn"
>
<Send size={20} />
</button>
</div>
)}
</div>
</div>,
document.body
)
}