373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
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
|
||
)
|
||
}
|