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) && (
-

+
)}
@@ -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()}>
+ {/* Хедер */}
+
+
+ {/* Список пользователей */}
+
+ {users.length === 0 ? (
+
+ ) : (
+
+ {users.map((user) => {
+ const isOwnProfile = user._id === currentUser?.id
+ const isFollowing = userStates[user._id]?.isFollowing || false
+
+ return (
+
handleUserClick(user._id)}
+ >
+

{ 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') ? (
<>
-
+
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;
+}
+