Update files

This commit is contained in:
glpshchn 2025-12-05 00:45:02 +03:00
parent a19c4bca62
commit fbeb53d96f
5 changed files with 429 additions and 56 deletions

View File

@ -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;
}

View File

@ -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">
<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 ? (
{loadingPost ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка...</p>
</div>
) : 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,8 +288,55 @@ 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>
)
})

View File

@ -334,6 +334,7 @@ export default function PostCard({ post, currentUser, onUpdate }) {
{showComments && createPortal(
<CommentsModal
post={post}
currentUser={currentUser}
onClose={() => setShowComments(false)}
onUpdate={onUpdate}
/>,

View File

@ -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>
);
}

View File

@ -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