nakama/frontend/src/pages/MediaFurry.jsx

527 lines
17 KiB
React
Raw Normal View History

2025-12-15 07:28:47 +00:00
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X, Plus, ArrowLeft } from 'lucide-react'
import { searchFurry, getFurryTags } from '../utils/api'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import CreatePostModal from '../components/CreatePostModal'
import api from '../utils/api'
import './MediaSearch.css'
export default function MediaFurry({ user }) {
const navigate = useNavigate()
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([])
const [selectionMode, setSelectionMode] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false)
const touchStartX = useRef(0)
const touchEndX = useRef(0)
const isVideoUrl = (url = '') => {
if (!url) return false
const clean = url.split('?')[0].toLowerCase()
return clean.endsWith('.mp4') || clean.endsWith('.webm') || clean.endsWith('.mov') || clean.endsWith('.m4v')
}
useEffect(() => {
if (query.length > 1 && showTagSuggestions) {
loadTagSuggestions()
} else {
setTagSuggestions([])
}
}, [query, showTagSuggestions])
const loadTagSuggestions = async () => {
try {
const queryParts = query.trim().split(/\s+/)
const lastTag = queryParts[queryParts.length - 1] || query.trim()
if (!lastTag || lastTag.length < 1) {
setTagSuggestions([])
return
}
const furryTags = await getFurryTags(lastTag)
if (furryTags && Array.isArray(furryTags)) {
setTagSuggestions(furryTags.slice(0, 10))
}
} catch (error) {
console.error('Ошибка загрузки тегов:', error)
setTagSuggestions([])
}
}
const handleSearch = async (searchQuery = query, page = 1, append = false) => {
if (!searchQuery.trim()) return
try {
if (page === 1) {
setLoading(true)
setResults([])
} else {
setLoadingMore(true)
}
hapticFeedback('light')
setShowTagSuggestions(false)
const furryResults = await searchFurry(searchQuery, { limit: 320, page })
const allResults = Array.isArray(furryResults) ? furryResults : []
const hasMoreResults = allResults.length === 320
if (append) {
setResults(prev => [...prev, ...allResults])
} else {
setResults(allResults)
setCurrentPage(1)
}
setHasMore(hasMoreResults)
setCurrentPage(page)
setTagSuggestions([])
if (allResults.length > 0) {
hapticFeedback('success')
} else if (page === 1) {
hapticFeedback('error')
}
} catch (error) {
console.error('Ошибка поиска:', error)
hapticFeedback('error')
if (page === 1) {
setResults([])
}
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const loadMore = () => {
if (!loadingMore && hasMore && query.trim()) {
handleSearch(query, currentPage + 1, true)
}
}
const handleTagClick = (tagName) => {
const queryParts = query.trim().split(/\s+/)
const existingTags = queryParts.slice(0, -1).filter(t => t.trim())
const newQuery = existingTags.length > 0
? [...existingTags, tagName].join(' ')
: tagName
setQuery(newQuery)
setShowTagSuggestions(false)
handleSearch(newQuery)
}
const openViewer = (index) => {
if (selectionMode) {
toggleImageSelection(index)
} else {
setCurrentIndex(index)
setShowViewer(true)
hapticFeedback('light')
}
}
const toggleImageSelection = (index) => {
const imageId = `${results[index].source}-${results[index].id}`
if (selectedImages.includes(imageId)) {
setSelectedImages(selectedImages.filter(id => id !== imageId))
} else {
setSelectedImages([...selectedImages, imageId])
}
hapticFeedback('light')
}
const toggleSelectionMode = () => {
setSelectionMode(!selectionMode)
setSelectedImages([])
hapticFeedback('light')
}
const handleSendSelected = async () => {
if (selectedImages.length === 0) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const selectedPhotos = results.filter((img, index) => {
const imageId = `${img.source}-${img.id}`
return selectedImages.includes(imageId)
})
const photos = selectedPhotos.map(img => ({
url: img.url,
caption: `${img.source} - ${img.id}`
}))
await api.post('/bot/send-photos', {
userId: telegramUser.id,
photos: photos
})
hapticFeedback('success')
alert(`${selectedImages.length} изображений отправлено в ваш Telegram!`)
setSelectedImages([])
setSelectionMode(false)
} else {
alert('Функция доступна только в Telegram')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки')
}
}
const handleNext = () => {
if (currentIndex < results.length - 1) {
setCurrentIndex(currentIndex + 1)
hapticFeedback('light')
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
hapticFeedback('light')
}
}
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX
}
const handleTouchMove = (e) => {
touchEndX.current = e.touches[0].clientX
}
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current
const threshold = 50
if (Math.abs(diff) > threshold) {
if (diff > 0) {
handleNext()
} else {
handlePrev()
}
}
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') {
handlePrev()
} else if (e.key === 'ArrowRight') {
handleNext()
} else if (e.key === 'Escape') {
setShowViewer(false)
}
}
useEffect(() => {
if (showViewer) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [showViewer, currentIndex])
const handleDownload = async () => {
const currentImage = results[currentIndex]
if (!currentImage) return
try {
hapticFeedback('light')
const telegramUser = getTelegramUser()
if (telegramUser) {
const caption = `${currentImage.source} - ID: ${currentImage.id}\nТеги: ${currentImage.tags.slice(0, 3).join(', ')}`
await api.post('/bot/send-photo', {
userId: telegramUser.id,
photoUrl: currentImage.url,
caption: caption
})
hapticFeedback('success')
alert('✅ Изображение отправлено в ваш Telegram!')
} else {
const response = await fetch(currentImage.url)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `nakama-${currentImage.id}.jpg`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
hapticFeedback('success')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки. Проверьте настройки бота.')
}
}
const handleCreatePost = () => {
const currentImage = results[currentIndex]
setShowViewer(false)
setShowCreatePost(true)
hapticFeedback('light')
}
return (
<div className="media-search-page">
<div className="media-search-header">
<button className="back-btn" onClick={() => navigate('/media')}>
<ArrowLeft size={24} />
</button>
2025-12-15 08:04:16 +00:00
<h1>Furry</h1>
2025-12-15 07:28:47 +00:00
{results.length > 0 && (
<button
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
onClick={toggleSelectionMode}
>
{selectionMode ? 'Отмена' : 'Выбрать'}
</button>
)}
</div>
<div className="search-container">
<div className="search-input-wrapper">
<SearchIcon size={20} className="search-icon" />
<input
type="text"
placeholder="Поиск по тегам..."
value={query}
onChange={e => {
setQuery(e.target.value)
setShowTagSuggestions(true)
}}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSearch()
}
}}
/>
{query && (
<button className="clear-btn" onClick={() => setQuery('')}>
<X size={18} />
</button>
)}
<button
className="search-submit-btn"
onClick={() => handleSearch()}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</button>
</div>
{tagSuggestions.length > 0 && showTagSuggestions && (
<div className="tag-suggestions">
{tagSuggestions.map((tag, index) => (
<button
key={index}
className="tag-suggestion"
onClick={() => handleTagClick(tag.name)}
>
<span className="tag-name">{tag.name}</span>
<span className="tag-count">{tag.count?.toLocaleString()}</span>
</button>
))}
</div>
)}
</div>
<div className="search-results">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Поиск...</p>
</div>
) : results.length === 0 && query ? (
<div className="empty-state">
<p>Ничего не найдено</p>
<span>Попробуйте другие теги</span>
</div>
) : results.length === 0 ? (
<div className="empty-state">
<SearchIcon size={48} color="var(--text-secondary)" />
<p>Введите теги для поиска</p>
<span>Используйте e621 теги</span>
</div>
) : (
<>
<div className="results-grid">
{results.map((item, index) => {
const imageId = `${item.source}-${item.id}`
const isSelected = selectedImages.includes(imageId)
return (
<div
key={imageId}
className={`result-item card ${isSelected ? 'selected' : ''}`}
onClick={() => openViewer(index)}
>
<img src={item.preview} alt={`Result ${index}`} />
<div className="result-overlay">
<span className="result-source">{item.source}</span>
<span className="result-rating">{item.rating}</span>
</div>
{selectionMode && (
<div className="selection-checkbox">
{isSelected && <span></span>}
</div>
)}
</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 && (
<div className="send-selected-bar">
<button className="send-selected-btn" onClick={handleSendSelected}>
Отправить в Telegram ({selectedImages.length})
</button>
</div>
)}
</>
)}
</div>
{showViewer && results[currentIndex] && createPortal(
<div className="image-viewer">
<div className="viewer-header">
<button className="viewer-btn" onClick={() => setShowViewer(false)}>
<X size={24} />
</button>
<span className="viewer-counter">
{currentIndex + 1} / {results.length}
</span>
<div className="viewer-actions">
<button className="viewer-btn" onClick={handleCreatePost} title="Создать пост">
<Plus size={24} />
</button>
<button className="viewer-btn" onClick={handleDownload} title="Отправить в ЛС">
<Download size={24} />
</button>
</div>
</div>
<div
className="viewer-content"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={(e) => e.stopPropagation()}
>
{isVideoUrl(results[currentIndex].url) ? (
<video
src={results[currentIndex].url}
controls
autoPlay
loop
muted
playsInline
poster={results[currentIndex].preview}
draggable={false}
/>
) : (
<img
src={results[currentIndex].url}
alt="Full view"
draggable={false}
/>
)}
<div className="swipe-hint">
<ChevronLeft size={20} style={{ opacity: currentIndex > 0 ? 1 : 0.3 }} />
<span>Свайпайте для переключения</span>
<ChevronRight size={20} style={{ opacity: currentIndex < results.length - 1 ? 1 : 0.3 }} />
</div>
</div>
<div className="viewer-nav">
<button
className="nav-btn"
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ opacity: currentIndex === 0 ? 0.3 : 1 }}
>
<ChevronLeft size={32} />
</button>
<button
className="nav-btn"
onClick={handleNext}
disabled={currentIndex === results.length - 1}
style={{ opacity: currentIndex === results.length - 1 ? 0.3 : 1 }}
>
<ChevronRight size={32} />
</button>
</div>
<div className="viewer-info">
<div className="info-tags">
{results[currentIndex].tags.slice(0, 5).map((tag, i) => (
<span key={i} className="info-tag">{tag}</span>
))}
</div>
<div className="info-stats">
<span>Score: {results[currentIndex].score}</span>
<span>Source: {results[currentIndex].source}</span>
</div>
</div>
</div>,
document.body
)}
{showCreatePost && (
<CreatePostModal
user={user}
onClose={() => setShowCreatePost(false)}
onPostCreated={() => {
setShowCreatePost(false)
setShowViewer(false)
}}
initialImage={results[currentIndex]?.url}
/>
)}
</div>
)
}