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 для прокси // Используем более мягкий rate limiter для прокси
router.get('/proxy/:encodedUrl', proxyLimiter, async (req, res) => { router.get('/proxy/:encodedUrl', async (req, res) => {
try { try {
const { encodedUrl } = req.params; const { encodedUrl } = req.params;

View File

@ -115,6 +115,35 @@
gap: 16px; 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 { .empty-comments {
padding: 60px 20px; padding: 60px 20px;
text-align: center; text-align: center;

View File

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, Send } from 'lucide-react' import { X, Send } from 'lucide-react'
import { commentPost } from '../utils/api' import { commentPost, getPosts } from '../utils/api'
import { hapticFeedback } from '../utils/telegram' import { hapticFeedback } from '../utils/telegram'
import { decodeHtmlEntities } from '../utils/htmlEntities' import { decodeHtmlEntities } from '../utils/htmlEntities'
import './CommentsModal.css' import './CommentsModal.css'
@ -9,7 +9,50 @@ import './CommentsModal.css'
export default function CommentsModal({ post, onClose, onUpdate }) { export default function CommentsModal({ post, onClose, onUpdate }) {
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [loading, setLoading] = useState(false) 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 () => { const handleSubmit = async () => {
if (!comment.trim()) return if (!comment.trim()) return
@ -19,9 +62,11 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
hapticFeedback('light') hapticFeedback('light')
const result = await commentPost(post._id, comment) const result = await commentPost(post._id, comment)
setComments(result.comments) setComments(result.comments || [])
setComment('') setComment('')
hapticFeedback('success') hapticFeedback('success')
// Обновить полный пост
await loadFullPost()
onUpdate() onUpdate()
} catch (error) { } catch (error) {
console.error('Ошибка добавления комментария:', error) console.error('Ошибка добавления комментария:', error)
@ -50,6 +95,10 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
} }
} }
if (!post) {
return null
}
return createPortal( return createPortal(
<div <div
className="comments-modal-overlay" className="comments-modal-overlay"
@ -68,33 +117,42 @@ export default function CommentsModal({ post, onClose, onUpdate }) {
</div> </div>
{/* Пост */} {/* Пост */}
<div className="post-preview"> {loadingPost ? (
<div className="preview-author"> <div className="post-preview">
<img <div className="loading-state">
src={post.author?.photoUrl || '/default-avatar.png'} <div className="spinner" />
alt={post.author?.username || post.author?.firstName || 'User'} <p>Загрузка...</p>
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>
</div> </div>
</div> </div>
) : (
{post.content && ( <div className="post-preview">
<div className="preview-content">{decodeHtmlEntities(post.content)}</div> <div className="preview-author">
)} <img
src={displayPost.author?.photoUrl || '/default-avatar.png'}
{((post.images && post.images.length > 0) || post.imageUrl) && ( alt={displayPost.author?.username || displayPost.author?.firstName || 'User'}
<div className="preview-image"> className="preview-avatar"
<img src={post.images?.[0] || post.imageUrl} alt="Post" /> 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>
)}
</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"> <div className="comments-list">

View File

@ -49,13 +49,13 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px; padding: 12px 16px;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
flex-shrink: 0; flex-shrink: 0;
} }
.follow-list-header h2 { .follow-list-header h2 {
font-size: 18px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
@ -102,13 +102,21 @@
padding: 8px 0; padding: 8px 0;
} }
.user-item-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 16px;
}
.user-item { .user-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding: 12px 16px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
padding: 4px;
border-radius: 8px;
} }
.user-item:active { .user-item:active {
@ -116,8 +124,8 @@
} }
.user-avatar { .user-avatar {
width: 48px; width: 36px;
height: 48px; height: 36px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
@ -129,7 +137,7 @@
} }
.user-name { .user-name {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
@ -139,7 +147,7 @@
} }
.user-username { .user-username {
font-size: 13px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -150,21 +158,22 @@
.follow-btn { .follow-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 8px 16px; gap: 8px;
border-radius: 20px; width: 100%;
padding: 10px 18px;
border-radius: 12px;
background: var(--button-accent); background: var(--button-accent);
color: white; color: white;
border: none; border: none;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s ease;
flex-shrink: 0;
} }
.follow-btn:active { .follow-btn:active {
opacity: 0.8; opacity: 0.85;
} }
.follow-btn.following { .follow-btn.following {

View File

@ -87,23 +87,24 @@ export default function FollowListModal({ users, title, onClose, currentUser })
const isFollowing = userStates[user._id]?.isFollowing || false const isFollowing = userStates[user._id]?.isFollowing || false
return ( return (
<div <div key={user._id} className="user-item-wrapper">
key={user._id} <div
className="user-item" className="user-item"
onClick={() => handleUserClick(user._id)} onClick={() => handleUserClick(user._id)}
> >
<img <img
src={user.photoUrl || '/default-avatar.png'} src={user.photoUrl || '/default-avatar.png'}
alt={user.username || user.firstName || 'User'} alt={user.username || user.firstName || 'User'}
className="user-avatar" className="user-avatar"
onError={(e) => { e.target.src = '/default-avatar.png' }} onError={(e) => { e.target.src = '/default-avatar.png' }}
/> />
<div className="user-info"> <div className="user-info">
<div className="user-name"> <div className="user-name">
{user.firstName || ''} {user.lastName || ''} {user.firstName || ''} {user.lastName || ''}
{!user.firstName && !user.lastName && 'Пользователь'} {!user.firstName && !user.lastName && 'Пользователь'}
</div>
<div className="user-username">@{user.username || user.firstName || 'user'}</div>
</div> </div>
<div className="user-username">@{user.username || user.firstName || 'user'}</div>
</div> </div>
{!isOwnProfile && ( {!isOwnProfile && (
@ -113,12 +114,12 @@ export default function FollowListModal({ users, title, onClose, currentUser })
> >
{isFollowing ? ( {isFollowing ? (
<> <>
<UserMinus size={16} /> <UserMinus size={18} />
<span>Отписаться</span> <span>Отписаться</span>
</> </>
) : ( ) : (
<> <>
<UserPlus size={16} /> <UserPlus size={18} />
<span>Подписаться</span> <span>Подписаться</span>
</> </>
)} )}

View File

@ -475,3 +475,48 @@
color: rgba(255, 255, 255, 0.7); 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 [query, setQuery] = useState('')
const [results, setResults] = useState([]) const [results, setResults] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [tagSuggestions, setTagSuggestions] = useState([]) 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 [currentIndex, setCurrentIndex] = useState(0)
const [showViewer, setShowViewer] = useState(false) const [showViewer, setShowViewer] = useState(false)
const [selectedImages, setSelectedImages] = useState([]) const [selectedImages, setSelectedImages] = useState([])
@ -30,12 +34,12 @@ const isVideoUrl = (url = '') => {
} }
useEffect(() => { useEffect(() => {
if (query.length > 1) { if (query.length > 1 && showTagSuggestions) {
loadTagSuggestions() loadTagSuggestions()
} else { } else {
setTagSuggestions([]) setTagSuggestions([])
} }
}, [query, mode]) }, [query, mode, showTagSuggestions])
const loadTagSuggestions = async () => { const loadTagSuggestions = async () => {
try { 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 if (!searchQuery.trim()) return
try { try {
setLoading(true) if (page === 1) {
setLoading(true)
setResults([])
} else {
setLoadingMore(true)
}
hapticFeedback('light') hapticFeedback('light')
setResults([]) setShowTagSuggestions(false)
let allResults = [] let allResults = []
let hasMoreResults = false
if (mode === 'furry') { if (mode === 'furry') {
try { try {
const furryResults = await searchFurry(searchQuery, { limit: 320, page: 1 }) const furryResults = await searchFurry(searchQuery, { limit: 320, page })
if (Array.isArray(furryResults)) { if (Array.isArray(furryResults)) {
allResults = [...allResults, ...furryResults] allResults = [...allResults, ...furryResults]
hasMoreResults = furryResults.length === 320
} }
} catch (error) { } catch (error) {
console.error('Ошибка e621 поиска:', error) console.error('Ошибка e621 поиска:', error)
@ -113,29 +125,47 @@ const isVideoUrl = (url = '') => {
if (mode === 'anime') { if (mode === 'anime') {
try { try {
const animeResults = await searchAnime(searchQuery, { limit: 320, page: 1 }) const animeResults = await searchAnime(searchQuery, { limit: 320, page })
if (Array.isArray(animeResults)) { if (Array.isArray(animeResults)) {
allResults = [...allResults, ...animeResults] allResults = [...allResults, ...animeResults]
hasMoreResults = animeResults.length === 320
} }
} catch (error) { } catch (error) {
console.error('Ошибка Gelbooru поиска:', error) console.error('Ошибка Gelbooru поиска:', error)
} }
} }
setResults(allResults) if (append) {
setResults(prev => [...prev, ...allResults])
} else {
setResults(allResults)
setCurrentPage(1)
}
setHasMore(hasMoreResults)
setCurrentPage(page)
setTagSuggestions([]) setTagSuggestions([])
if (allResults.length > 0) { if (allResults.length > 0) {
hapticFeedback('success') hapticFeedback('success')
} else { } else if (page === 1) {
hapticFeedback('error') hapticFeedback('error')
} }
} catch (error) { } catch (error) {
console.error('Ошибка поиска:', error) console.error('Ошибка поиска:', error)
hapticFeedback('error') hapticFeedback('error')
setResults([]) if (page === 1) {
setResults([])
}
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingMore(false)
}
}
const loadMore = () => {
if (!loadingMore && hasMore && query.trim()) {
handleSearch(query, currentPage + 1, true)
} }
} }
@ -149,6 +179,7 @@ const isVideoUrl = (url = '') => {
: tagName : tagName
setQuery(newQuery) setQuery(newQuery)
setShowTagSuggestions(false)
handleSearch(newQuery) handleSearch(newQuery)
} }
@ -370,8 +401,15 @@ const isVideoUrl = (url = '') => {
type="text" type="text"
placeholder="Поиск по тегам..." placeholder="Поиск по тегам..."
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => {
onKeyPress={e => e.key === 'Enter' && handleSearch()} setQuery(e.target.value)
setShowTagSuggestions(true)
}}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/> />
{query && ( {query && (
<button className="clear-btn" onClick={() => setQuery('')}> <button className="clear-btn" onClick={() => setQuery('')}>
@ -388,7 +426,7 @@ const isVideoUrl = (url = '') => {
</div> </div>
{/* Подсказки тегов */} {/* Подсказки тегов */}
{tagSuggestions.length > 0 && ( {tagSuggestions.length > 0 && showTagSuggestions && (
<div className="tag-suggestions"> <div className="tag-suggestions">
{tagSuggestions.map((tag, index) => ( {tagSuggestions.map((tag, index) => (
<button <button
@ -451,6 +489,20 @@ const isVideoUrl = (url = '') => {
</div> </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 && ( {selectionMode && selectedImages.length > 0 && (