nakama/frontend/src/pages/Search.jsx

521 lines
17 KiB
React
Raw Normal View History

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 }) {
const [mode, setMode] = useState(user.settings?.searchPreference || 'mixed')
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
useEffect(() => {
if (query.length > 1) {
loadTagSuggestions()
} else {
setTagSuggestions([])
}
}, [query, mode])
const loadTagSuggestions = async () => {
try {
let tags = []
if (mode === 'furry' || mode === 'mixed') {
2025-11-03 22:41:34 +00:00
try {
const furryTags = await getFurryTags(query)
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
}
if (mode === 'anime' || mode === 'mixed') {
2025-11-03 22:41:34 +00:00
try {
const animeTags = await getAnimeTags(query)
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 = []
if (mode === 'furry' || mode === 'mixed') {
2025-11-03 22:41:34 +00:00
try {
const furryResults = await searchFurry(searchQuery, { limit: 30 })
if (furryResults && Array.isArray(furryResults)) {
allResults = [...allResults, ...furryResults]
}
} catch (error) {
console.error('Ошибка e621 поиска:', error)
// Продолжаем поиск даже если e621 не работает
}
2025-11-03 20:35:01 +00:00
}
if (mode === 'anime' || mode === 'mixed') {
2025-11-03 22:41:34 +00:00
try {
const animeResults = await searchAnime(searchQuery, { limit: 30 })
if (animeResults && Array.isArray(animeResults)) {
allResults = [...allResults, ...animeResults]
}
} catch (error) {
console.error('Ошибка Gelbooru поиска:', error)
// Продолжаем поиск даже если Gelbooru не работает
}
2025-11-03 20:35:01 +00:00
}
// Перемешать результаты если mixed режим
if (mode === 'mixed') {
allResults = allResults.sort(() => Math.random() - 0.5)
}
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) => {
setQuery(tagName)
handleSearch(tagName)
}
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>
<button
className={`mode-btn ${mode === 'mixed' ? 'active' : ''}`}
onClick={() => setMode('mixed')}
>
Mixed
</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
{/* Кнопка отправки выбранных */}
{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}
>
<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>
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>
)
}