nakama/frontend/src/components/PostCard.jsx

463 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-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 MusicAttachment from './MusicAttachment'
import './PostCard.css'
const TAG_COLORS = {
furry: '#FF8A33',
anime: '#4A90E2',
other: '#A0A0A0'
}
const TAG_NAMES = {
furry: 'Furry',
anime: 'Anime',
other: 'Other'
}
export default function PostCard({ post, currentUser, onUpdate, onAuthRequired }) {
const navigate = useNavigate()
const [liked, setLiked] = useState(post.likes?.includes(currentUser.id) || false)
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)
const [menuButtonPosition, setMenuButtonPosition] = useState(null)
const isGuest = currentUser?.isGuest === true
// Проверка на существование автора
if (!post.author) {
console.warn('[PostCard] Post without author:', post._id)
return null // Не показываем посты без автора
}
// Поддержка и старого поля imageUrl и нового images
// Фильтруем старые URL из Telegram API
const allImages = post.images && post.images.length > 0 ? post.images : (post.imageUrl ? [post.imageUrl] : [])
const images = allImages.filter(img => {
// Игнорируем старые URL из Telegram API
if (img && typeof img === 'string' && img.includes('api.telegram.org/file/bot')) {
console.warn('[PostCard] Skipping old Telegram URL:', img)
return false
}
return true
})
const handleLike = async () => {
// Проверка: гость не может лайкать
if (isGuest) {
if (onAuthRequired) {
onAuthRequired('Войдите, чтобы лайкать посты')
}
hapticFeedback('error')
return
}
try {
hapticFeedback('light')
const result = await likePost(post._id)
setLiked(result.liked)
setLikesCount(result.likes)
if (result.liked) {
hapticFeedback('success')
}
} catch (error) {
console.error('Ошибка лайка:', error)
}
}
const handleDelete = async () => {
const confirmed = await showConfirm('Удалить этот пост?')
if (confirmed) {
try {
await deletePost(post._id)
hapticFeedback('success')
onUpdate()
} catch (error) {
console.error('Ошибка удаления:', error)
}
}
}
const formatDate = (date) => {
const d = new Date(date)
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
const goToProfile = () => {
if (post.author?._id) {
navigate(`/user/${post.author._id}`)
}
}
const openFullView = () => {
if (images.length > 0) {
setShowFullView(true)
hapticFeedback('light')
}
}
const handleNext = () => {
if (currentImageIndex < images.length - 1) {
setCurrentImageIndex(currentImageIndex + 1)
hapticFeedback('light')
}
}
const handlePrev = () => {
if (currentImageIndex > 0) {
setCurrentImageIndex(currentImageIndex - 1)
hapticFeedback('light')
}
}
const handleDownloadImage = async () => {
try {
hapticFeedback('light')
const imageUrl = images[currentImageIndex]
await sendPhotoToTelegram(imageUrl)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка отправки фото:', error)
hapticFeedback('error')
}
}
const handleRepost = () => {
try {
hapticFeedback('light')
// Получить имя бота из переменных окружения или использовать дефолтное
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'NakamaSpaceBot'
// Создать deeplink для открытия поста в миниапп
// Используем startapp для миниаппов - это правильный формат для передачи параметра в миниапп
const deeplink = `https://t.me/${botName}?startapp=post_${post._id}`
// Открыть нативное окно "Поделиться" в Telegram
const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(deeplink)}&text=${encodeURIComponent('Смотри пост в Nakama!')}`
openTelegramLink(shareUrl)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка репоста:', error)
hapticFeedback('error')
}
}
return (
<div className="post-card card fade-in">
{/* Хедер поста */}
<div className="post-header">
<div className="post-author" onClick={goToProfile}>
<img
src={post.author?.photoUrl || '/default-avatar.png'}
alt={post.author?.username || post.author?.firstName || 'User'}
className="author-avatar"
onError={(e) => {
e.target.src = '/default-avatar.png'
}}
/>
<div className="author-info">
<div className="author-name">
{post.author?.firstName || ''} {post.author?.lastName || ''}
{!post.author?.firstName && !post.author?.lastName && 'Пользователь'}
</div>
<div className="post-date">
@{post.author?.username || post.author?.firstName || 'user'} · {formatDate(post.createdAt)}
</div>
</div>
</div>
<button
className="menu-btn"
data-menu-button={post._id}
onClick={(e) => {
e.stopPropagation()
const button = e.currentTarget
const rect = button.getBoundingClientRect()
setMenuButtonPosition({
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right
})
setShowMenu(true)
}}
>
<MoreVertical size={20} />
</button>
</div>
{/* Контент */}
{post.content && (
<div className="post-content">
{decodeHtmlEntities(post.content)}
</div>
)}
{/* Изображения */}
{images.length > 0 && (
<div className="post-images">
<div className="image-carousel" style={{ cursor: 'pointer', position: 'relative' }} onClick={openFullView}>
<img src={images[currentImageIndex]} alt={`Image ${currentImageIndex + 1}`} />
{images.length > 1 && (
<>
{/* Левая зона для переключения на предыдущее изображение */}
<div
className="carousel-zone carousel-zone-left"
onClick={(e) => {
e.stopPropagation()
if (currentImageIndex > 0) {
handlePrev()
} else {
openFullView()
}
}}
style={{ cursor: 'pointer' }}
/>
{/* Правая зона для переключения на следующее изображение */}
<div
className="carousel-zone carousel-zone-right"
onClick={(e) => {
e.stopPropagation()
if (currentImageIndex < images.length - 1) {
handleNext()
} else {
openFullView()
}
}}
style={{ cursor: 'pointer' }}
/>
<div className="carousel-dots">
{images.map((_, index) => (
<span
key={index}
className={`dot ${index === currentImageIndex ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setCurrentImageIndex(index); }}
/>
))}
</div>
</>
)}
{/* Индикатор что можно открыть fullview */}
<div className="fullview-hint" onClick={(e) => { e.stopPropagation(); openFullView(); }}>
<ZoomIn size={20} />
</div>
</div>
</div>
)}
{/* Музыкальный трек (после изображений, компактно) */}
{post.attachedTrack && (
<div className="post-music">
<MusicAttachment track={post.attachedTrack} />
</div>
)}
{/* Теги */}
<div className="post-tags">
{post.tags.map((tag, index) => {
// Для известных тегов используем цвета и имена из объектов
// Для неизвестных тегов используем дефолтный цвет и само имя тега
const tagColor = TAG_COLORS[tag] || '#A0A0A0' // Дефолтный серый цвет
// Если нет имени в TAG_NAMES, используем сам тег с заглавной первой буквой
const tagName = TAG_NAMES[tag] || (tag.charAt(0).toUpperCase() + tag.slice(1))
return (
<span
key={index}
className="post-tag"
style={{ backgroundColor: tagColor }}
>
{tagName}
</span>
)
})}
{post.isNSFW && (
<span className="nsfw-badge">NSFW</span>
)}
</div>
{/* Действия */}
<div className="post-actions">
<button
className={`action-btn ${liked ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation()
handleLike()
}}
>
<Heart size={20} fill={liked ? '#FF3B30' : 'none'} stroke={liked ? '#FF3B30' : 'currentColor'} />
<span>{likesCount}</span>
</button>
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation()
setShowComments(true)
}}
>
<MessageCircle size={20} stroke="currentColor" />
<span>{post.comments.length}</span>
</button>
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation()
handleRepost()
}}
title="Поделиться постом"
>
<Share2 size={20} stroke="currentColor" />
</button>
</div>
{/* Fullview модал через Portal */}
{showFullView && createPortal(
<div
className="image-fullview"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
setShowFullView(false)
}}
>
<div className="fullview-header" onClick={(e) => e.stopPropagation()}>
<button className="fullview-btn" onClick={(e) => { e.stopPropagation(); setShowFullView(false); }}>
<X size={24} />
</button>
<span className="fullview-counter">
{currentImageIndex + 1} / {images.length}
</span>
<button
className="fullview-btn"
onClick={(e) => { e.stopPropagation(); handleDownloadImage(); }}
title="Отправить в Telegram"
>
<Download size={24} />
</button>
</div>
<div className="fullview-content" onClick={(e) => e.stopPropagation()}>
<img
src={images[currentImageIndex]}
alt={`Full view ${currentImageIndex + 1}`}
draggable={false}
onClick={(e) => e.stopPropagation()}
/>
{images.length > 1 && (
<>
{/* Левая зона для переключения на предыдущее изображение */}
<div
className="fullview-zone fullview-zone-left"
onClick={(e) => {
e.stopPropagation()
if (currentImageIndex > 0) {
handlePrev()
}
}}
/>
{/* Правая зона для переключения на следующее изображение */}
<div
className="fullview-zone fullview-zone-right"
onClick={(e) => {
e.stopPropagation()
if (currentImageIndex < images.length - 1) {
handleNext()
}
}}
/>
{/* Кнопки навигации - всегда видимые, но неактивные когда нельзя переключить */}
<button
className="fullview-nav-btn prev"
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
disabled={currentImageIndex === 0}
style={{ opacity: currentImageIndex === 0 ? 0.3 : 1 }}
>
<ChevronLeft size={32} />
</button>
<button
className="fullview-nav-btn next"
onClick={(e) => { e.stopPropagation(); handleNext(); }}
disabled={currentImageIndex === images.length - 1}
style={{ opacity: currentImageIndex === images.length - 1 ? 0.3 : 1 }}
>
<ChevronRight size={32} />
</button>
</>
)}
</div>
{images.length > 1 && (
<div className="fullview-dots" onClick={(e) => e.stopPropagation()}>
{images.map((_, index) => (
<span
key={index}
className={`fullview-dot ${index === currentImageIndex ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setCurrentImageIndex(index); }}
/>
))}
</div>
)}
</div>,
document.body
)}
{/* Модалка комментариев через Portal */}
{showComments && createPortal(
<CommentsModal
post={post}
currentUser={currentUser}
onClose={() => setShowComments(false)}
onUpdate={onUpdate}
/>,
document.body
)}
{/* Модалка меню через Portal */}
{showMenu && createPortal(
<PostMenu
post={post}
currentUser={currentUser}
onClose={() => {
setShowMenu(false)
setMenuButtonPosition(null)
}}
onDelete={async () => {
await handleDelete()
setShowMenu(false)
setMenuButtonPosition(null)
}}
onUpdate={onUpdate}
buttonPosition={menuButtonPosition}
/>,
document.body
)}
</div>
)
}