Update files

This commit is contained in:
glpshchn 2025-12-04 23:47:07 +03:00
parent cf953709ff
commit ed8917e8dd
7 changed files with 268 additions and 74 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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