Update files
This commit is contained in:
parent
a19c4bca62
commit
fbeb53d96f
|
|
@ -5,19 +5,23 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-secondary);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10500;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.comments-modal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 50vh;
|
||||
height: 50vh;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
/* Убираем pointer-events и touch-action чтобы клики работали */
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +167,8 @@
|
|||
.comment-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
|
|
@ -256,3 +262,89 @@
|
|||
[data-theme="dark"] .send-btn svg {
|
||||
stroke: #000000;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comment-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.comment-action-btn:hover {
|
||||
background: var(--divider-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-action-btn.delete:hover {
|
||||
background: #FF3B30;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comment-action-btn svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.comment-edit-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.comment-edit-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--divider-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-edit-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.comment-edit-btn.save {
|
||||
background: var(--button-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comment-edit-btn.save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.comment-edit-btn.cancel {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.comment-edit-btn:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Send } from 'lucide-react'
|
||||
import { commentPost, getPosts } from '../utils/api'
|
||||
import { hapticFeedback } from '../utils/telegram'
|
||||
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 }) {
|
||||
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 отсутствует
|
||||
|
|
@ -139,6 +141,86 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -159,56 +241,15 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
<div style={{ width: 40 }} />
|
||||
</div>
|
||||
|
||||
{/* Пост */}
|
||||
{!hasValidPost ? (
|
||||
<div className="post-preview">
|
||||
<div className="loading-state">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : loadingPost ? (
|
||||
<div className="post-preview">
|
||||
{/* Список комментариев */}
|
||||
{hasValidPost && (
|
||||
<div className="comments-list">
|
||||
{loadingPost ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="post-preview">
|
||||
{displayPost.author && (
|
||||
<div className="preview-author">
|
||||
<img
|
||||
src={displayPost.author?.photoUrl || '/default-avatar.png'}
|
||||
alt={displayPost.author?.username || displayPost.author?.firstName || 'User'}
|
||||
className="preview-avatar"
|
||||
onError={(e) => { e.target.src = '/default-avatar.png' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="preview-name">
|
||||
{displayPost.author?.firstName || ''} {displayPost.author?.lastName || ''}
|
||||
{!displayPost.author?.firstName && !displayPost.author?.lastName && 'Пользователь'}
|
||||
</div>
|
||||
<div className="preview-username">@{displayPost.author?.username || displayPost.author?.firstName || 'user'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPost.content && (
|
||||
<div className="preview-content">{decodeHtmlEntities(displayPost.content)}</div>
|
||||
)}
|
||||
|
||||
{((displayPost.images && displayPost.images.length > 0) || displayPost.imageUrl) && (
|
||||
<div className="preview-image">
|
||||
<img src={displayPost.images?.[0] || displayPost.imageUrl} alt="Post" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список комментариев */}
|
||||
{hasValidPost && (
|
||||
<div className="comments-list">
|
||||
{comments.length === 0 ? (
|
||||
) : comments.length === 0 ? (
|
||||
<div className="empty-comments">
|
||||
<p>Пока нет комментариев</p>
|
||||
<span>Будьте первым!</span>
|
||||
|
|
@ -227,6 +268,10 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
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
|
||||
|
|
@ -243,9 +288,56 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
</span>
|
||||
<span className="comment-time">{formatDate(c.createdAt)}</span>
|
||||
</div>
|
||||
<p className="comment-text">{decodeHtmlEntities(c.content)}</p>
|
||||
{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 значения
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
|||
{showComments && createPortal(
|
||||
<CommentsModal
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
onClose={() => setShowComments(false)}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import {
|
|||
initiateAddAdmin,
|
||||
confirmAddAdmin,
|
||||
initiateRemoveAdmin,
|
||||
confirmRemoveAdmin
|
||||
confirmRemoveAdmin,
|
||||
getPostComments,
|
||||
deleteComment
|
||||
} from './utils/api';
|
||||
import { io } from 'socket.io-client';
|
||||
import {
|
||||
|
|
@ -31,7 +33,8 @@ import {
|
|||
Ban,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Crown
|
||||
Crown,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
const TABS = [
|
||||
|
|
@ -112,6 +115,10 @@ export default function App() {
|
|||
const chatSocketRef = useRef(null);
|
||||
const chatListRef = useRef(null);
|
||||
|
||||
// Comments modal
|
||||
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -415,6 +422,41 @@ export default function App() {
|
|||
loadUsers();
|
||||
};
|
||||
|
||||
const handleOpenComments = async (postId) => {
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
const post = await getPostComments(postId);
|
||||
setCommentsModal({
|
||||
postId,
|
||||
comments: post.comments || [],
|
||||
postContent: post.content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки комментариев:', error);
|
||||
alert('Ошибка загрузки комментариев');
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId) => {
|
||||
if (!commentsModal) return;
|
||||
if (!window.confirm('Удалить этот комментарий?')) return;
|
||||
|
||||
try {
|
||||
await deleteComment(commentsModal.postId, commentId);
|
||||
// Обновить список комментариев
|
||||
const post = await getPostComments(commentsModal.postId);
|
||||
setCommentsModal({
|
||||
...commentsModal,
|
||||
comments: post.comments || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления комментария:', error);
|
||||
alert('Ошибка удаления комментария');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReportStatus = async (reportId, status) => {
|
||||
await updateReportStatus(reportId, { status });
|
||||
loadReports();
|
||||
|
|
@ -581,6 +623,10 @@ export default function App() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="list-item-actions">
|
||||
<button className="btn" onClick={() => handleOpenComments(post.id)}>
|
||||
<MessageSquare size={16} />
|
||||
Комментарии ({post.commentsCount || 0})
|
||||
</button>
|
||||
<button className="btn" onClick={() => handlePostEdit(post)}>
|
||||
<Edit size={16} />
|
||||
Редактировать
|
||||
|
|
@ -1023,6 +1069,142 @@ export default function App() {
|
|||
</nav>
|
||||
|
||||
<main className="content">{renderContent()}</main>
|
||||
|
||||
{/* Модалка комментариев */}
|
||||
{commentsModal && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setCommentsModal(null)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid var(--divider-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>Комментарии</h2>
|
||||
<button
|
||||
onClick={() => setCommentsModal(null)}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
overflowY: 'auto',
|
||||
flex: 1
|
||||
}}>
|
||||
{commentsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Loader2 className="spin" size={32} />
|
||||
</div>
|
||||
) : commentsModal.comments.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
||||
Нет комментариев
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{commentsModal.comments.map((comment) => (
|
||||
<div
|
||||
key={comment._id || comment.id}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
gap: '12px'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<strong style={{ fontSize: '14px' }}>
|
||||
{comment.author?.firstName || comment.author?.username || 'Пользователь'}
|
||||
</strong>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteComment(comment._id || comment.id)}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: '#FF3B30',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}
|
||||
title="Удалить комментарий"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,5 +104,11 @@ export const initiateRemoveAdmin = (adminId) =>
|
|||
export const confirmRemoveAdmin = (adminId, code) =>
|
||||
api.post('/mod-app/admins/confirm-remove', { adminId, code }).then((res) => res.data)
|
||||
|
||||
export const getPostComments = (postId) =>
|
||||
api.get(`/posts/${postId}`).then((res) => res.data.post)
|
||||
|
||||
export const deleteComment = (postId, commentId) =>
|
||||
api.delete(`/posts/${postId}/comments/${commentId}`).then((res) => res.data)
|
||||
|
||||
export default api
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue