2025-11-03 22:17:25 +00:00
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
|
|
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X, Plus } from 'lucide-react'
|
2025-11-03 20:35:01 +00:00
|
|
|
|
import { searchFurry, searchAnime, getFurryTags, getAnimeTags } from '../utils/api'
|
2025-11-03 22:17:25 +00:00
|
|
|
|
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
|
|
|
|
|
|
import CreatePostModal from '../components/CreatePostModal'
|
|
|
|
|
|
import api from '../utils/api'
|
2025-11-03 20:35:01 +00:00
|
|
|
|
import './Search.css'
|
|
|
|
|
|
|
|
|
|
|
|
export default function Search({ user }) {
|
2025-11-10 20:13:22 +00:00
|
|
|
|
const initialMode = user.settings?.searchPreference === 'anime' ? 'anime' : 'furry'
|
|
|
|
|
|
const [mode, setMode] = useState(initialMode)
|
2025-11-03 20:35:01 +00:00
|
|
|
|
const [query, setQuery] = useState('')
|
|
|
|
|
|
const [results, setResults] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [tagSuggestions, setTagSuggestions] = useState([])
|
|
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(0)
|
|
|
|
|
|
const [showViewer, setShowViewer] = useState(false)
|
2025-11-03 22:17:25 +00:00
|
|
|
|
const [selectedImages, setSelectedImages] = useState([])
|
|
|
|
|
|
const [selectionMode, setSelectionMode] = useState(false)
|
|
|
|
|
|
const [showCreatePost, setShowCreatePost] = useState(false)
|
|
|
|
|
|
const [imageForPost, setImageForPost] = useState(null)
|
|
|
|
|
|
const touchStartX = useRef(0)
|
|
|
|
|
|
const touchEndX = useRef(0)
|
2025-11-03 20:35:01 +00:00
|
|
|
|
|
2025-11-10 20:35:21 +00:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (query.length > 1) {
|
|
|
|
|
|
loadTagSuggestions()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTagSuggestions([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [query, mode])
|
|
|
|
|
|
|
|
|
|
|
|
const loadTagSuggestions = async () => {
|
|
|
|
|
|
try {
|
2025-11-04 21:51:05 +00:00
|
|
|
|
// Разбить query по пробелам и взять последний тег для автокомплита
|
|
|
|
|
|
const queryParts = query.trim().split(/\s+/)
|
|
|
|
|
|
const lastTag = queryParts[queryParts.length - 1] || query.trim()
|
|
|
|
|
|
|
|
|
|
|
|
// Если нет текста для поиска, не загружаем предложения
|
|
|
|
|
|
if (!lastTag || lastTag.length < 1) {
|
|
|
|
|
|
setTagSuggestions([])
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
let tags = []
|
|
|
|
|
|
|
2025-11-04 21:51:05 +00:00
|
|
|
|
if (mode === 'furry') {
|
2025-11-03 22:41:34 +00:00
|
|
|
|
try {
|
2025-11-04 21:51:05 +00:00
|
|
|
|
const furryTags = await getFurryTags(lastTag)
|
2025-11-03 22:41:34 +00:00
|
|
|
|
if (furryTags && Array.isArray(furryTags)) {
|
|
|
|
|
|
tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка загрузки e621 тегов:', error)
|
|
|
|
|
|
// Продолжаем даже если e621 не работает
|
|
|
|
|
|
}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 21:51:05 +00:00
|
|
|
|
if (mode === 'anime') {
|
2025-11-03 22:41:34 +00:00
|
|
|
|
try {
|
2025-11-04 21:51:05 +00:00
|
|
|
|
const animeTags = await getAnimeTags(lastTag)
|
2025-11-03 22:41:34 +00:00
|
|
|
|
if (animeTags && Array.isArray(animeTags)) {
|
|
|
|
|
|
tags = [...tags, ...animeTags.map(t => ({ ...t, source: 'gelbooru' }))]
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка загрузки Gelbooru тегов:', error)
|
|
|
|
|
|
// Продолжаем даже если Gelbooru не работает
|
|
|
|
|
|
}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Убрать дубликаты
|
|
|
|
|
|
const uniqueTags = tags.reduce((acc, tag) => {
|
|
|
|
|
|
if (!acc.find(t => t.name === tag.name)) {
|
|
|
|
|
|
acc.push(tag)
|
|
|
|
|
|
}
|
|
|
|
|
|
return acc
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
setTagSuggestions(uniqueTags.slice(0, 10))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка загрузки тегов:', error)
|
2025-11-03 22:41:34 +00:00
|
|
|
|
setTagSuggestions([])
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearch = async (searchQuery = query) => {
|
|
|
|
|
|
if (!searchQuery.trim()) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
hapticFeedback('light')
|
|
|
|
|
|
setResults([])
|
|
|
|
|
|
|
|
|
|
|
|
let allResults = []
|
2025-11-10 20:13:22 +00:00
|
|
|
|
|
|
|
|
|
|
if (mode === 'furry') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const furryResults = await searchFurry(searchQuery, { limit: 320, page: 1 })
|
|
|
|
|
|
if (Array.isArray(furryResults)) {
|
|
|
|
|
|
allResults = [...allResults, ...furryResults]
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка e621 поиска:', error)
|
2025-11-03 22:41:34 +00:00
|
|
|
|
}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
|
|
|
|
|
|
if (mode === 'anime') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const animeResults = await searchAnime(searchQuery, { limit: 320, page: 1 })
|
|
|
|
|
|
if (Array.isArray(animeResults)) {
|
|
|
|
|
|
allResults = [...allResults, ...animeResults]
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка Gelbooru поиска:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
setResults(allResults)
|
|
|
|
|
|
setTagSuggestions([])
|
|
|
|
|
|
|
|
|
|
|
|
if (allResults.length > 0) {
|
|
|
|
|
|
hapticFeedback('success')
|
2025-11-03 22:41:34 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
hapticFeedback('error')
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка поиска:', error)
|
|
|
|
|
|
hapticFeedback('error')
|
2025-11-03 22:41:34 +00:00
|
|
|
|
setResults([])
|
2025-11-03 20:35:01 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleTagClick = (tagName) => {
|
2025-11-04 21:51:05 +00:00
|
|
|
|
// Разбить текущий query по пробелам
|
|
|
|
|
|
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)
|
|
|
|
|
|
handleSearch(newQuery)
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openViewer = (index) => {
|
2025-11-03 22:17:25 +00:00
|
|
|
|
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([])
|
2025-11-03 20:35:01 +00:00
|
|
|
|
hapticFeedback('light')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 22:17:25 +00:00
|
|
|
|
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('Ошибка отправки')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
const handleNext = () => {
|
|
|
|
|
|
if (currentIndex < results.length - 1) {
|
|
|
|
|
|
setCurrentIndex(currentIndex + 1)
|
|
|
|
|
|
hapticFeedback('light')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePrev = () => {
|
|
|
|
|
|
if (currentIndex > 0) {
|
|
|
|
|
|
setCurrentIndex(currentIndex - 1)
|
|
|
|
|
|
hapticFeedback('light')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 22:17:25 +00:00
|
|
|
|
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])
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
const handleDownload = async () => {
|
|
|
|
|
|
const currentImage = results[currentIndex]
|
|
|
|
|
|
if (!currentImage) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
hapticFeedback('light')
|
2025-11-03 22:17:25 +00:00
|
|
|
|
|
|
|
|
|
|
const telegramUser = getTelegramUser()
|
|
|
|
|
|
|
|
|
|
|
|
if (telegramUser) {
|
|
|
|
|
|
// Отправить через backend в ЛС с ботом
|
|
|
|
|
|
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 {
|
|
|
|
|
|
// Fallback - обычное скачивание
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
} catch (error) {
|
2025-11-03 22:17:25 +00:00
|
|
|
|
console.error('Ошибка:', error)
|
2025-11-03 20:35:01 +00:00
|
|
|
|
hapticFeedback('error')
|
2025-11-03 22:17:25 +00:00
|
|
|
|
alert('Ошибка отправки. Проверьте настройки бота.')
|
2025-11-03 20:35:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 22:17:25 +00:00
|
|
|
|
const handleCreatePost = () => {
|
|
|
|
|
|
const currentImage = results[currentIndex]
|
|
|
|
|
|
setImageForPost(currentImage.url)
|
|
|
|
|
|
setShowViewer(false)
|
|
|
|
|
|
setShowCreatePost(true)
|
|
|
|
|
|
hapticFeedback('light')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePostCreated = (newPost) => {
|
|
|
|
|
|
setShowCreatePost(false)
|
|
|
|
|
|
setImageForPost(null)
|
|
|
|
|
|
hapticFeedback('success')
|
|
|
|
|
|
alert('✅ Пост создан!')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 20:35:01 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="search-page">
|
|
|
|
|
|
{/* Хедер */}
|
|
|
|
|
|
<div className="search-header">
|
|
|
|
|
|
<h1>Поиск</h1>
|
2025-11-03 22:17:25 +00:00
|
|
|
|
{results.length > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
|
|
|
|
|
|
onClick={toggleSelectionMode}
|
|
|
|
|
|
>
|
|
|
|
|
|
{selectionMode ? 'Отмена' : 'Выбрать'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Режимы поиска */}
|
|
|
|
|
|
<div className="search-modes">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`mode-btn ${mode === 'furry' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setMode('furry')}
|
|
|
|
|
|
style={{ color: mode === 'furry' ? 'var(--tag-furry)' : undefined }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Furry
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`mode-btn ${mode === 'anime' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setMode('anime')}
|
|
|
|
|
|
style={{ color: mode === 'anime' ? 'var(--tag-anime)' : undefined }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Anime
|
|
|
|
|
|
</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)}
|
|
|
|
|
|
onKeyPress={e => e.key === 'Enter' && handleSearch()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{query && (
|
|
|
|
|
|
<button className="clear-btn" onClick={() => setQuery('')}>
|
|
|
|
|
|
<X size={18} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Подсказки тегов */}
|
|
|
|
|
|
{tagSuggestions.length > 0 && (
|
|
|
|
|
|
<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 и gelbooru</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2025-11-03 22:19:27 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
2025-11-03 22:17:25 +00:00
|
|
|
|
</div>
|
2025-11-03 22:19:27 +00:00
|
|
|
|
{selectionMode && (
|
|
|
|
|
|
<div className="selection-checkbox">
|
|
|
|
|
|
{isSelected && <span>✓</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2025-11-03 22:17:25 +00:00
|
|
|
|
</div>
|
2025-11-03 22:19:27 +00:00
|
|
|
|
|
2025-11-04 21:51:05 +00:00
|
|
|
|
{/* Кнопка загрузки дополнительных результатов */}
|
|
|
|
|
|
|
2025-11-03 22:19:27 +00:00
|
|
|
|
{/* Кнопка отправки выбранных */}
|
|
|
|
|
|
{selectionMode && selectedImages.length > 0 && (
|
|
|
|
|
|
<div className="send-selected-bar">
|
|
|
|
|
|
<button className="send-selected-btn" onClick={handleSendSelected}>
|
|
|
|
|
|
Отправить в Telegram ({selectedImages.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2025-11-03 20:35:01 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Просмотрщик изображений */}
|
|
|
|
|
|
{showViewer && results[currentIndex] && (
|
2025-11-03 22:17:25 +00:00
|
|
|
|
<div className="image-viewer">
|
2025-11-03 20:35:01 +00:00
|
|
|
|
<div className="viewer-header">
|
|
|
|
|
|
<button className="viewer-btn" onClick={() => setShowViewer(false)}>
|
|
|
|
|
|
<X size={24} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="viewer-counter">
|
|
|
|
|
|
{currentIndex + 1} / {results.length}
|
|
|
|
|
|
</span>
|
2025-11-03 22:17:25 +00:00
|
|
|
|
<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>
|
2025-11-03 20:35:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-03 22:17:25 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="viewer-content"
|
|
|
|
|
|
onTouchStart={handleTouchStart}
|
|
|
|
|
|
onTouchMove={handleTouchMove}
|
|
|
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
|
|
|
>
|
2025-11-10 20:35:21 +00:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-11-03 22:17:25 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Индикатор свайпа */}
|
|
|
|
|
|
<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>
|
2025-11-03 20:35:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="viewer-nav">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="nav-btn"
|
2025-11-03 22:17:25 +00:00
|
|
|
|
onClick={handlePrev}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
disabled={currentIndex === 0}
|
2025-11-03 22:17:25 +00:00
|
|
|
|
style={{ opacity: currentIndex === 0 ? 0.3 : 1 }}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft size={32} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="nav-btn"
|
2025-11-03 22:17:25 +00:00
|
|
|
|
onClick={handleNext}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
disabled={currentIndex === results.length - 1}
|
2025-11-03 22:17:25 +00:00
|
|
|
|
style={{ opacity: currentIndex === results.length - 1 ? 0.3 : 1 }}
|
2025-11-03 20:35:01 +00:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|