nakama/frontend/src/pages/Search.jsx

575 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X, Plus } from 'lucide-react'
import { searchFurry, searchAnime, getFurryTags, getAnimeTags } from '../utils/api'
import { hapticFeedback, getTelegramUser } from '../utils/telegram'
import CreatePostModal from '../components/CreatePostModal'
import api from '../utils/api'
import './Search.css'
export default function Search({ user }) {
const initialMode = user.settings?.searchPreference === 'anime' ? 'anime' : 'furry'
const [mode, setMode] = useState(initialMode)
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)
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)
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) {
loadTagSuggestions()
} else {
setTagSuggestions([])
}
}, [query, mode])
const loadTagSuggestions = async () => {
try {
// Разбить query по пробелам и взять последний тег для автокомплита
const queryParts = query.trim().split(/\s+/)
const lastTag = queryParts[queryParts.length - 1] || query.trim()
// Если нет текста для поиска, не загружаем предложения
if (!lastTag || lastTag.length < 1) {
setTagSuggestions([])
return
}
let tags = []
if (mode === 'furry') {
try {
const furryTags = await getFurryTags(lastTag)
if (furryTags && Array.isArray(furryTags)) {
tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
}
} catch (error) {
console.error('Ошибка загрузки e621 тегов:', error)
// Продолжаем даже если e621 не работает
}
}
if (mode === 'anime') {
try {
const animeTags = await getAnimeTags(lastTag)
if (animeTags && Array.isArray(animeTags)) {
tags = [...tags, ...animeTags.map(t => ({ ...t, source: 'gelbooru' }))]
}
} catch (error) {
console.error('Ошибка загрузки Gelbooru тегов:', error)
// Продолжаем даже если Gelbooru не работает
}
}
// Убрать дубликаты
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)
setTagSuggestions([])
}
}
const handleSearch = async (searchQuery = query) => {
if (!searchQuery.trim()) return
try {
setLoading(true)
hapticFeedback('light')
setResults([])
let allResults = []
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)
}
}
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)
}
}
setResults(allResults)
setTagSuggestions([])
if (allResults.length > 0) {
hapticFeedback('success')
} else {
hapticFeedback('error')
}
} catch (error) {
console.error('Ошибка поиска:', error)
hapticFeedback('error')
setResults([])
} finally {
setLoading(false)
}
}
const handleTagClick = (tagName) => {
// Разбить текущий 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)
}
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) {
// Отправить через 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')
}
} catch (error) {
console.error('Ошибка:', error)
hapticFeedback('error')
alert('Ошибка отправки. Проверьте настройки бота.')
}
}
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('✅ Пост создан!')
}
return (
<div className="search-page">
{/* Хедер */}
<div className="search-header">
<h1>Поиск</h1>
{results.length > 0 && (
<button
className={`selection-toggle ${selectionMode ? 'active' : ''}`}
onClick={toggleSelectionMode}
>
{selectionMode ? 'Отмена' : 'Выбрать'}
</button>
)}
</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>
)}
<button
className="search-submit-btn"
onClick={() => handleSearch()}
disabled={!query.trim() || loading}
>
<SearchIcon size={20} />
</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>
) : (
<>
<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>
{/* Кнопка загрузки дополнительных результатов */}
{/* Кнопка отправки выбранных */}
{selectionMode && selectedImages.length > 0 && (
<div className="send-selected-bar">
<button className="send-selected-btn" onClick={handleSendSelected}>
Отправить в Telegram ({selectedImages.length})
</button>
</div>
)}
</>
)}
</div>
{/* Просмотрщик изображений через Portal */}
{showViewer && results[currentIndex] && createPortal(
<div
className="image-viewer"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<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>
)
}