Update files
This commit is contained in:
parent
cf953709ff
commit
ed8917e8dd
|
|
@ -57,7 +57,7 @@ function createProxyUrl(originalUrl) {
|
|||
|
||||
// Эндпоинт для проксирования изображений
|
||||
// Используем более мягкий rate limiter для прокси
|
||||
router.get('/proxy/:encodedUrl', proxyLimiter, async (req, res) => {
|
||||
router.get('/proxy/:encodedUrl', async (req, res) => {
|
||||
try {
|
||||
const { encodedUrl } = req.params;
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,35 @@
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--divider-color);
|
||||
border-top-color: var(--button-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-comments {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Send } from 'lucide-react'
|
||||
import { commentPost } from '../utils/api'
|
||||
import { commentPost, getPosts } from '../utils/api'
|
||||
import { hapticFeedback } from '../utils/telegram'
|
||||
import { decodeHtmlEntities } from '../utils/htmlEntities'
|
||||
import './CommentsModal.css'
|
||||
|
|
@ -9,7 +9,50 @@ import './CommentsModal.css'
|
|||
export default function CommentsModal({ post, onClose, onUpdate }) {
|
||||
const [comment, setComment] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [comments, setComments] = useState(post.comments || [])
|
||||
const [comments, setComments] = useState([])
|
||||
const [fullPost, setFullPost] = useState(post)
|
||||
const [loadingPost, setLoadingPost] = useState(false)
|
||||
|
||||
// Загрузить полные данные поста с комментариями
|
||||
useEffect(() => {
|
||||
if (post?._id) {
|
||||
loadFullPost()
|
||||
} else {
|
||||
setFullPost(post)
|
||||
setComments(post?.comments || [])
|
||||
}
|
||||
}, [post?._id])
|
||||
|
||||
const loadFullPost = async () => {
|
||||
try {
|
||||
setLoadingPost(true)
|
||||
const response = await getPosts()
|
||||
const foundPost = response.posts?.find(p => p._id === post._id)
|
||||
if (foundPost) {
|
||||
setFullPost(foundPost)
|
||||
setComments(foundPost.comments || [])
|
||||
} else {
|
||||
// Fallback на переданный post
|
||||
setFullPost(post)
|
||||
setComments(post?.comments || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CommentsModal] Ошибка загрузки поста:', error)
|
||||
// Fallback на переданный post
|
||||
setFullPost(post)
|
||||
setComments(post?.comments || [])
|
||||
} finally {
|
||||
setLoadingPost(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка на существование поста
|
||||
if (!post) {
|
||||
console.error('[CommentsModal] Post is missing')
|
||||
return null
|
||||
}
|
||||
|
||||
const displayPost = fullPost || post
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!comment.trim()) return
|
||||
|
|
@ -19,9 +62,11 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
hapticFeedback('light')
|
||||
|
||||
const result = await commentPost(post._id, comment)
|
||||
setComments(result.comments)
|
||||
setComments(result.comments || [])
|
||||
setComment('')
|
||||
hapticFeedback('success')
|
||||
// Обновить полный пост
|
||||
await loadFullPost()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления комментария:', error)
|
||||
|
|
@ -50,6 +95,10 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="comments-modal-overlay"
|
||||
|
|
@ -68,33 +117,42 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
|
|||
</div>
|
||||
|
||||
{/* Пост */}
|
||||
<div className="post-preview">
|
||||
<div className="preview-author">
|
||||
<img
|
||||
src={post.author?.photoUrl || '/default-avatar.png'}
|
||||
alt={post.author?.username || post.author?.firstName || 'User'}
|
||||
className="preview-avatar"
|
||||
onError={(e) => { e.target.src = '/default-avatar.png' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="preview-name">
|
||||
{post.author?.firstName || ''} {post.author?.lastName || ''}
|
||||
{!post.author?.firstName && !post.author?.lastName && 'Пользователь'}
|
||||
</div>
|
||||
<div className="preview-username">@{post.author?.username || post.author?.firstName || 'user'}</div>
|
||||
{loadingPost ? (
|
||||
<div className="post-preview">
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.content && (
|
||||
<div className="preview-content">{decodeHtmlEntities(post.content)}</div>
|
||||
)}
|
||||
|
||||
{((post.images && post.images.length > 0) || post.imageUrl) && (
|
||||
<div className="preview-image">
|
||||
<img src={post.images?.[0] || post.imageUrl} alt="Post" />
|
||||
) : (
|
||||
<div className="post-preview">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Список комментариев */}
|
||||
<div className="comments-list">
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.follow-list-header h2 {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
|
|
@ -102,13 +102,21 @@
|
|||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.user-item-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-item:active {
|
||||
|
|
@ -116,8 +124,8 @@
|
|||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -129,7 +137,7 @@
|
|||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
|
|
@ -139,7 +147,7 @@
|
|||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -150,21 +158,22 @@
|
|||
.follow-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--button-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.follow-btn:active {
|
||||
opacity: 0.8;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.follow-btn.following {
|
||||
|
|
|
|||
|
|
@ -87,23 +87,24 @@ export default function FollowListModal({ users, title, onClose, currentUser })
|
|||
const isFollowing = userStates[user._id]?.isFollowing || false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user._id}
|
||||
className="user-item"
|
||||
onClick={() => handleUserClick(user._id)}
|
||||
>
|
||||
<img
|
||||
src={user.photoUrl || '/default-avatar.png'}
|
||||
alt={user.username || user.firstName || 'User'}
|
||||
className="user-avatar"
|
||||
onError={(e) => { e.target.src = '/default-avatar.png' }}
|
||||
/>
|
||||
<div className="user-info">
|
||||
<div className="user-name">
|
||||
{user.firstName || ''} {user.lastName || ''}
|
||||
{!user.firstName && !user.lastName && 'Пользователь'}
|
||||
<div key={user._id} className="user-item-wrapper">
|
||||
<div
|
||||
className="user-item"
|
||||
onClick={() => handleUserClick(user._id)}
|
||||
>
|
||||
<img
|
||||
src={user.photoUrl || '/default-avatar.png'}
|
||||
alt={user.username || user.firstName || 'User'}
|
||||
className="user-avatar"
|
||||
onError={(e) => { e.target.src = '/default-avatar.png' }}
|
||||
/>
|
||||
<div className="user-info">
|
||||
<div className="user-name">
|
||||
{user.firstName || ''} {user.lastName || ''}
|
||||
{!user.firstName && !user.lastName && 'Пользователь'}
|
||||
</div>
|
||||
<div className="user-username">@{user.username || user.firstName || 'user'}</div>
|
||||
</div>
|
||||
<div className="user-username">@{user.username || user.firstName || 'user'}</div>
|
||||
</div>
|
||||
|
||||
{!isOwnProfile && (
|
||||
|
|
@ -113,12 +114,12 @@ export default function FollowListModal({ users, title, onClose, currentUser })
|
|||
>
|
||||
{isFollowing ? (
|
||||
<>
|
||||
<UserMinus size={16} />
|
||||
<UserMinus size={18} />
|
||||
<span>Отписаться</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus size={16} />
|
||||
<UserPlus size={18} />
|
||||
<span>Подписаться</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -475,3 +475,48 @@
|
|||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 24px;
|
||||
background: var(--button-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.load-more-btn:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-more .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--divider-color);
|
||||
border-top-color: var(--button-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-more p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export default function Search({ user }) {
|
|||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [tagSuggestions, setTagSuggestions] = useState([])
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(true)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [showViewer, setShowViewer] = useState(false)
|
||||
const [selectedImages, setSelectedImages] = useState([])
|
||||
|
|
@ -30,12 +34,12 @@ const isVideoUrl = (url = '') => {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length > 1) {
|
||||
if (query.length > 1 && showTagSuggestions) {
|
||||
loadTagSuggestions()
|
||||
} else {
|
||||
setTagSuggestions([])
|
||||
}
|
||||
}, [query, mode])
|
||||
}, [query, mode, showTagSuggestions])
|
||||
|
||||
const loadTagSuggestions = async () => {
|
||||
try {
|
||||
|
|
@ -90,21 +94,29 @@ const isVideoUrl = (url = '') => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSearch = async (searchQuery = query) => {
|
||||
const handleSearch = async (searchQuery = query, page = 1, append = false) => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
if (page === 1) {
|
||||
setLoading(true)
|
||||
setResults([])
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
hapticFeedback('light')
|
||||
setResults([])
|
||||
setShowTagSuggestions(false)
|
||||
|
||||
let allResults = []
|
||||
let hasMoreResults = false
|
||||
|
||||
if (mode === 'furry') {
|
||||
try {
|
||||
const furryResults = await searchFurry(searchQuery, { limit: 320, page: 1 })
|
||||
const furryResults = await searchFurry(searchQuery, { limit: 320, page })
|
||||
if (Array.isArray(furryResults)) {
|
||||
allResults = [...allResults, ...furryResults]
|
||||
hasMoreResults = furryResults.length === 320
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка e621 поиска:', error)
|
||||
|
|
@ -113,29 +125,47 @@ const isVideoUrl = (url = '') => {
|
|||
|
||||
if (mode === 'anime') {
|
||||
try {
|
||||
const animeResults = await searchAnime(searchQuery, { limit: 320, page: 1 })
|
||||
const animeResults = await searchAnime(searchQuery, { limit: 320, page })
|
||||
if (Array.isArray(animeResults)) {
|
||||
allResults = [...allResults, ...animeResults]
|
||||
hasMoreResults = animeResults.length === 320
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка Gelbooru поиска:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setResults(allResults)
|
||||
if (append) {
|
||||
setResults(prev => [...prev, ...allResults])
|
||||
} else {
|
||||
setResults(allResults)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
setHasMore(hasMoreResults)
|
||||
setCurrentPage(page)
|
||||
setTagSuggestions([])
|
||||
|
||||
if (allResults.length > 0) {
|
||||
hapticFeedback('success')
|
||||
} else {
|
||||
} else if (page === 1) {
|
||||
hapticFeedback('error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка поиска:', error)
|
||||
hapticFeedback('error')
|
||||
setResults([])
|
||||
if (page === 1) {
|
||||
setResults([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loadingMore && hasMore && query.trim()) {
|
||||
handleSearch(query, currentPage + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +179,7 @@ const isVideoUrl = (url = '') => {
|
|||
: tagName
|
||||
|
||||
setQuery(newQuery)
|
||||
setShowTagSuggestions(false)
|
||||
handleSearch(newQuery)
|
||||
}
|
||||
|
||||
|
|
@ -370,8 +401,15 @@ const isVideoUrl = (url = '') => {
|
|||
type="text"
|
||||
placeholder="Поиск по тегам..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && handleSearch()}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value)
|
||||
setShowTagSuggestions(true)
|
||||
}}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="clear-btn" onClick={() => setQuery('')}>
|
||||
|
|
@ -388,7 +426,7 @@ const isVideoUrl = (url = '') => {
|
|||
</div>
|
||||
|
||||
{/* Подсказки тегов */}
|
||||
{tagSuggestions.length > 0 && (
|
||||
{tagSuggestions.length > 0 && showTagSuggestions && (
|
||||
<div className="tag-suggestions">
|
||||
{tagSuggestions.map((tag, index) => (
|
||||
<button
|
||||
|
|
@ -451,6 +489,20 @@ const isVideoUrl = (url = '') => {
|
|||
</div>
|
||||
|
||||
{/* Кнопка загрузки дополнительных результатов */}
|
||||
{hasMore && !loadingMore && (
|
||||
<div className="load-more-container">
|
||||
<button className="load-more-btn" onClick={loadMore}>
|
||||
Загрузить еще
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingMore && (
|
||||
<div className="loading-more">
|
||||
<div className="spinner" />
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка отправки выбранных */}
|
||||
{selectionMode && selectedImages.length > 0 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue