diff --git a/backend/models/Notification.js b/backend/models/Notification.js index dc94f97..d9676a4 100644 --- a/backend/models/Notification.js +++ b/backend/models/Notification.js @@ -13,7 +13,7 @@ const NotificationSchema = new mongoose.Schema({ }, type: { type: String, - enum: ['follow', 'like', 'comment', 'mention'], + enum: ['follow', 'like', 'comment', 'mention', 'new_post'], required: true }, post: { diff --git a/backend/routes/posts.js b/backend/routes/posts.js index e5d9be7..16abb0b 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -174,6 +174,19 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa })); await Notification.insertMany(notifications); } + + // Создать уведомления для подписчиков о новом посте + const User = require('../models/User'); + const author = await User.findById(req.user._id).select('followers'); + if (author && author.followers && author.followers.length > 0) { + const newPostNotifications = author.followers.map(followerId => ({ + recipient: followerId, + sender: req.user._id, + type: 'new_post', + post: post._id + })); + await Notification.insertMany(newPostNotifications); + } res.status(201).json({ post }); } catch (error) { diff --git a/backend/routes/users.js b/backend/routes/users.js index 35b8e3b..6e19187 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -32,6 +32,8 @@ router.get('/:id', authenticate, async (req, res) => { bio: user.bio, followersCount: user.followers.length, followingCount: user.following.length, + followers: user.followers, + following: user.following, isFollowing, createdAt: user.createdAt } diff --git a/frontend/src/components/CommentsModal.jsx b/frontend/src/components/CommentsModal.jsx index fb8846f..d01be52 100644 --- a/frontend/src/components/CommentsModal.jsx +++ b/frontend/src/components/CommentsModal.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { X, Send } from 'lucide-react' import { commentPost } from '../utils/api' import { hapticFeedback } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' import './CommentsModal.css' export default function CommentsModal({ post, onClose, onUpdate }) { @@ -79,12 +80,12 @@ export default function CommentsModal({ post, onClose, onUpdate }) { {post.content && ( -
{post.content}
+
{decodeHtmlEntities(post.content)}
)} - {post.imageUrl && ( + {((post.images && post.images.length > 0) || post.imageUrl) && (
- Post + Post
)} @@ -115,7 +116,7 @@ export default function CommentsModal({ post, onClose, onUpdate }) { {formatDate(c.createdAt)} -

{c.content}

+

{decodeHtmlEntities(c.content)}

)) diff --git a/frontend/src/components/FollowListModal.css b/frontend/src/components/FollowListModal.css new file mode 100644 index 0000000..21fce4d --- /dev/null +++ b/frontend/src/components/FollowListModal.css @@ -0,0 +1,178 @@ +/* Overlay */ +.follow-list-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 10000; + display: flex; + align-items: flex-end; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal */ +.follow-list-modal { + width: 100%; + max-height: 80vh; + background: var(--bg-secondary); + border-radius: 20px 20px 0 0; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease-out; + overflow: hidden; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +/* Header */ +.follow-list-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--divider-color); + flex-shrink: 0; +} + +.follow-list-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.follow-list-header .close-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: transparent; + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + transition: background 0.2s; +} + +.follow-list-header .close-btn:active { + background: var(--bg-primary); +} + +/* Content */ +.follow-list-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.empty-state { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); +} + +.empty-state p { + font-size: 15px; + margin: 0; +} + +/* Users List */ +.users-list { + padding: 8px 0; +} + +.user-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; +} + +.user-item:active { + background: var(--bg-primary); +} + +.user-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-username { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Follow Button */ +.follow-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 20px; + background: var(--button-accent); + color: white; + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.follow-btn:active { + opacity: 0.8; +} + +.follow-btn.following { + background: var(--bg-primary); + color: var(--text-secondary); + border: 1px solid var(--divider-color); +} + +.follow-btn span { + white-space: nowrap; +} + + diff --git a/frontend/src/components/FollowListModal.jsx b/frontend/src/components/FollowListModal.jsx new file mode 100644 index 0000000..824b691 --- /dev/null +++ b/frontend/src/components/FollowListModal.jsx @@ -0,0 +1,131 @@ +import { X, UserPlus, UserMinus } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { useState } from 'react' +import { followUser, unfollowUser } from '../utils/api' +import { hapticFeedback } from '../utils/telegram' +import './FollowListModal.css' + +export default function FollowListModal({ users, title, onClose, currentUser }) { + const navigate = useNavigate() + const [userStates, setUserStates] = useState( + users.reduce((acc, user) => { + acc[user._id] = { + isFollowing: currentUser?.following?.some(f => f._id === user._id || f === user._id) || false + } + return acc + }, {}) + ) + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose() + } + } + + const handleUserClick = (userId) => { + hapticFeedback('light') + onClose() + navigate(`/user/${userId}`) + } + + const handleFollowToggle = async (userId, e) => { + e.stopPropagation() + + try { + hapticFeedback('light') + const isCurrentlyFollowing = userStates[userId]?.isFollowing || false + + if (isCurrentlyFollowing) { + await unfollowUser(userId) + setUserStates(prev => ({ + ...prev, + [userId]: { isFollowing: false } + })) + } else { + await followUser(userId) + setUserStates(prev => ({ + ...prev, + [userId]: { isFollowing: true } + })) + } + + hapticFeedback('success') + } catch (error) { + console.error('Ошибка подписки:', error) + hapticFeedback('error') + } + } + + return ( +
+
e.stopPropagation()}> + {/* Хедер */} +
+ +

{title}

+
+
+ + {/* Список пользователей */} +
+ {users.length === 0 ? ( +
+

Пока никого нет

+
+ ) : ( +
+ {users.map((user) => { + const isOwnProfile = user._id === currentUser?.id + const isFollowing = userStates[user._id]?.isFollowing || false + + return ( +
handleUserClick(user._id)} + > + {user.username { e.target.src = '/default-avatar.png' }} + /> +
+
+ {user.firstName || ''} {user.lastName || ''} + {!user.firstName && !user.lastName && 'Пользователь'} +
+
@{user.username || user.firstName || 'user'}
+
+ + {!isOwnProfile && ( + + )} +
+ ) + })} +
+ )} +
+
+
+ ) +} + diff --git a/frontend/src/components/PostCard.css b/frontend/src/components/PostCard.css index 5d4f388..196fc05 100644 --- a/frontend/src/components/PostCard.css +++ b/frontend/src/components/PostCard.css @@ -319,23 +319,25 @@ } .fullview-content { - flex: 1; + position: fixed; + top: 68px; + left: 0; + right: 0; + bottom: 60px; display: flex; align-items: center; justify-content: center; - position: relative; overflow: hidden; - margin-top: 68px; - margin-bottom: 80px; } .fullview-content img { max-width: 100%; - max-height: calc(100vh - 148px); + max-height: 100%; + width: auto; + height: auto; object-fit: contain; user-select: none; display: block; - min-height: 0; } .fullview-nav-btn { diff --git a/frontend/src/components/PostCard.jsx b/frontend/src/components/PostCard.jsx index e93da1f..5f42b2f 100644 --- a/frontend/src/components/PostCard.jsx +++ b/frontend/src/components/PostCard.jsx @@ -3,6 +3,9 @@ import { useNavigate } from 'react-router-dom' import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight, Download, Send, X, ZoomIn, Share2 } from 'lucide-react' import { likePost, deletePost, sendPhotoToTelegram } from '../utils/api' import { hapticFeedback, showConfirm, openTelegramLink } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' +import CommentsModal from './CommentsModal' +import PostMenu from './PostMenu' import './PostCard.css' const TAG_COLORS = { @@ -23,6 +26,8 @@ export default function PostCard({ post, currentUser, onUpdate }) { const [likesCount, setLikesCount] = useState(post.likes?.length || 0) const [currentImageIndex, setCurrentImageIndex] = useState(0) const [showFullView, setShowFullView] = useState(false) + const [showComments, setShowComments] = useState(false) + const [showMenu, setShowMenu] = useState(false) // Проверка на существование автора if (!post.author) { @@ -164,7 +169,7 @@ export default function PostCard({ post, currentUser, onUpdate }) { className="menu-btn" onClick={(e) => { e.stopPropagation() - navigate(`/post/${post._id}/menu`) + setShowMenu(true) }} > @@ -174,7 +179,7 @@ export default function PostCard({ post, currentUser, onUpdate }) { {/* Контент */} {post.content && (
- {post.content} + {decodeHtmlEntities(post.content)}
)} @@ -237,7 +242,7 @@ export default function PostCard({ post, currentUser, onUpdate }) { className="action-btn" onClick={(e) => { e.stopPropagation() - navigate(`/post/${post._id}/comments`) + setShowComments(true) }} > @@ -254,27 +259,6 @@ export default function PostCard({ post, currentUser, onUpdate }) { > - - {images.length > 0 && ( - - )}
{/* Fullview модал */} @@ -333,6 +317,28 @@ export default function PostCard({ post, currentUser, onUpdate }) { )} )} + + {/* Модалка комментариев */} + {showComments && ( + setShowComments(false)} + onUpdate={onUpdate} + /> + )} + + {/* Модалка меню */} + {showMenu && ( + setShowMenu(false)} + onDelete={async () => { + await handleDelete() + setShowMenu(false) + }} + /> + )} ) } diff --git a/frontend/src/pages/CommentsPage.jsx b/frontend/src/pages/CommentsPage.jsx index 9f227f9..d15d6ff 100644 --- a/frontend/src/pages/CommentsPage.jsx +++ b/frontend/src/pages/CommentsPage.jsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom' import { ArrowLeft, Send, Edit, Trash2 } from 'lucide-react' import { getPosts, commentPost, editComment, deleteComment } from '../utils/api' import { hapticFeedback, showConfirm } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' import './CommentsPage.css' export default function CommentsPage({ user }) { @@ -137,7 +138,7 @@ export default function CommentsPage({ user }) { {post.content && ( -
{post.content}
+
{decodeHtmlEntities(post.content)}
)} {images.length > 0 && ( @@ -258,7 +259,7 @@ export default function CommentsPage({ user }) { ) : ( -

{c.content}

+

{decodeHtmlEntities(c.content)}

)} diff --git a/frontend/src/pages/Notifications.jsx b/frontend/src/pages/Notifications.jsx index a3b8de0..7cac6df 100644 --- a/frontend/src/pages/Notifications.jsx +++ b/frontend/src/pages/Notifications.jsx @@ -3,27 +3,31 @@ import { useNavigate } from 'react-router-dom' import { Heart, MessageCircle, UserPlus, AtSign, CheckCheck } from 'lucide-react' import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../utils/api' import { hapticFeedback } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' import './Notifications.css' const NOTIFICATION_ICONS = { follow: UserPlus, like: Heart, comment: MessageCircle, - mention: AtSign + mention: AtSign, + new_post: Heart } const NOTIFICATION_COLORS = { follow: '#007AFF', like: '#FF3B30', comment: '#34C759', - mention: '#FF9500' + mention: '#FF9500', + new_post: '#5856D6' } const NOTIFICATION_TEXTS = { follow: 'подписался на вас', like: 'лайкнул ваш пост', comment: 'прокомментировал ваш пост', - mention: 'упомянул вас в посте' + mention: 'упомянул вас в посте', + new_post: 'опубликовал новый пост' } export default function Notifications({ user }) { @@ -187,7 +191,7 @@ export default function Notifications({ user }) { {notification.post && notification.post.content && (
- {notification.post.content.slice(0, 50)} + {decodeHtmlEntities(notification.post.content.slice(0, 50))} {notification.post.content.length > 50 && '...'}
)} diff --git a/frontend/src/pages/PostMenuPage.jsx b/frontend/src/pages/PostMenuPage.jsx index d6700a7..d5e1bd3 100644 --- a/frontend/src/pages/PostMenuPage.jsx +++ b/frontend/src/pages/PostMenuPage.jsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom' import { ArrowLeft, Trash2, Flag, Edit, Share2 } from 'lucide-react' import { getPosts, reportPost, deletePost, editPost } from '../utils/api' import { hapticFeedback, showConfirm, showAlert } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' import './PostMenuPage.css' export default function PostMenuPage({ user }) { @@ -203,7 +204,7 @@ export default function PostMenuPage({ user }) { {post.content && ( -
{post.content}
+
{decodeHtmlEntities(post.content)}
)} {(post.images && post.images.length > 0) || post.imageUrl ? ( @@ -221,11 +222,6 @@ export default function PostMenuPage({ user }) { {/* Меню */}
- - {(post.author._id === user.id) || (user.role === 'moderator' || user.role === 'admin') ? ( <> @@ -154,12 +158,12 @@ export default function Profile({ user, setUser }) {
-
+
setShowFollowers(true)} style={{ cursor: 'pointer' }}> {user.followersCount || 0} Подписчики
-
+
setShowFollowing(true)} style={{ cursor: 'pointer' }}> {user.followingCount || 0} Подписки
@@ -390,6 +394,26 @@ export default function Profile({ user, setUser }) {
)} + + {/* Модалка подписчиков */} + {showFollowers && ( + setShowFollowers(false)} + /> + )} + + {/* Модалка подписок */} + {showFollowing && ( + setShowFollowing(false)} + /> + )}
) } diff --git a/frontend/src/pages/UserProfile.jsx b/frontend/src/pages/UserProfile.jsx index ad73491..1b0b57e 100644 --- a/frontend/src/pages/UserProfile.jsx +++ b/frontend/src/pages/UserProfile.jsx @@ -3,7 +3,9 @@ import { useParams, useNavigate } from 'react-router-dom' import { ChevronLeft, Shield } from 'lucide-react' import { getUserProfile, getUserPosts, followUser } from '../utils/api' import { hapticFeedback } from '../utils/telegram' +import { decodeHtmlEntities } from '../utils/htmlEntities' import PostCard from '../components/PostCard' +import FollowListModal from '../components/FollowListModal' import './UserProfile.css' export default function UserProfile({ currentUser }) { @@ -14,6 +16,8 @@ export default function UserProfile({ currentUser }) { const [loading, setLoading] = useState(true) const [following, setFollowing] = useState(false) const [followersCount, setFollowersCount] = useState(0) + const [showFollowers, setShowFollowers] = useState(false) + const [showFollowing, setShowFollowing] = useState(false) useEffect(() => { loadProfile() @@ -102,18 +106,18 @@ export default function UserProfile({ currentUser }) { {user.bio && (
-

{user.bio}

+

{decodeHtmlEntities(user.bio)}

)}
-
+
setShowFollowers(true)} style={{ cursor: 'pointer' }}> {followersCount} Подписчики
-
+
setShowFollowing(true)} style={{ cursor: 'pointer' }}> {user.followingCount} Подписки
@@ -152,6 +156,26 @@ export default function UserProfile({ currentUser }) {
)}
+ + {/* Модалка подписчиков */} + {showFollowers && user && ( + setShowFollowers(false)} + /> + )} + + {/* Модалка подписок */} + {showFollowing && user && ( + setShowFollowing(false)} + /> + )}
) } diff --git a/frontend/src/utils/htmlEntities.js b/frontend/src/utils/htmlEntities.js new file mode 100644 index 0000000..43f102e --- /dev/null +++ b/frontend/src/utils/htmlEntities.js @@ -0,0 +1,12 @@ +// Декодировать HTML entities (например, / -> /) +export function decodeHtmlEntities(str = '') { + if (!str || typeof str !== 'string') { + return str; + } + + // Создаем временный элемент для декодирования + const textarea = document.createElement('textarea'); + textarea.innerHTML = str; + return textarea.value; +} +