295 lines
9.1 KiB
React
295 lines
9.1 KiB
React
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import { Search as SearchIcon, ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
|
||
|
|
import { searchFurry, searchAnime, getFurryTags, getAnimeTags } from '../utils/api'
|
||
|
|
import { hapticFeedback } from '../utils/telegram'
|
||
|
|
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)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (query.length > 1) {
|
||
|
|
loadTagSuggestions()
|
||
|
|
} else {
|
||
|
|
setTagSuggestions([])
|
||
|
|
}
|
||
|
|
}, [query, mode])
|
||
|
|
|
||
|
|
const loadTagSuggestions = async () => {
|
||
|
|
try {
|
||
|
|
let tags = []
|
||
|
|
|
||
|
|
if (mode === 'furry' || mode === 'mixed') {
|
||
|
|
const furryTags = await getFurryTags(query)
|
||
|
|
tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
|
||
|
|
}
|
||
|
|
|
||
|
|
if (mode === 'anime' || mode === 'mixed') {
|
||
|
|
const animeTags = await getAnimeTags(query)
|
||
|
|
tags = [...tags, ...animeTags.map(t => ({ ...t, source: '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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSearch = async (searchQuery = query) => {
|
||
|
|
if (!searchQuery.trim()) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
setLoading(true)
|
||
|
|
hapticFeedback('light')
|
||
|
|
setResults([])
|
||
|
|
|
||
|
|
let allResults = []
|
||
|
|
|
||
|
|
if (mode === 'furry' || mode === 'mixed') {
|
||
|
|
const furryResults = await searchFurry(searchQuery, { limit: 30 })
|
||
|
|
allResults = [...allResults, ...furryResults]
|
||
|
|
}
|
||
|
|
|
||
|
|
if (mode === 'anime' || mode === 'mixed') {
|
||
|
|
const animeResults = await searchAnime(searchQuery, { limit: 30 })
|
||
|
|
allResults = [...allResults, ...animeResults]
|
||
|
|
}
|
||
|
|
|
||
|
|
// Перемешать результаты если mixed режим
|
||
|
|
if (mode === 'mixed') {
|
||
|
|
allResults = allResults.sort(() => Math.random() - 0.5)
|
||
|
|
}
|
||
|
|
|
||
|
|
setResults(allResults)
|
||
|
|
setTagSuggestions([])
|
||
|
|
|
||
|
|
if (allResults.length > 0) {
|
||
|
|
hapticFeedback('success')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Ошибка поиска:', error)
|
||
|
|
hapticFeedback('error')
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTagClick = (tagName) => {
|
||
|
|
setQuery(tagName)
|
||
|
|
handleSearch(tagName)
|
||
|
|
}
|
||
|
|
|
||
|
|
const openViewer = (index) => {
|
||
|
|
setCurrentIndex(index)
|
||
|
|
setShowViewer(true)
|
||
|
|
hapticFeedback('light')
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleNext = () => {
|
||
|
|
if (currentIndex < results.length - 1) {
|
||
|
|
setCurrentIndex(currentIndex + 1)
|
||
|
|
hapticFeedback('light')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handlePrev = () => {
|
||
|
|
if (currentIndex > 0) {
|
||
|
|
setCurrentIndex(currentIndex - 1)
|
||
|
|
hapticFeedback('light')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDownload = async () => {
|
||
|
|
const currentImage = results[currentIndex]
|
||
|
|
if (!currentImage) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
hapticFeedback('light')
|
||
|
|
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')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="search-page">
|
||
|
|
{/* Хедер */}
|
||
|
|
<div className="search-header">
|
||
|
|
<h1>Поиск</h1>
|
||
|
|
</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>
|
||
|
|
) : (
|
||
|
|
<div className="results-grid">
|
||
|
|
{results.map((item, index) => (
|
||
|
|
<div
|
||
|
|
key={`${item.source}-${item.id}`}
|
||
|
|
className="result-item card"
|
||
|
|
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>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Просмотрщик изображений */}
|
||
|
|
{showViewer && results[currentIndex] && (
|
||
|
|
<div className="image-viewer" onClick={() => setShowViewer(false)}>
|
||
|
|
<div className="viewer-header">
|
||
|
|
<button className="viewer-btn" onClick={() => setShowViewer(false)}>
|
||
|
|
<X size={24} />
|
||
|
|
</button>
|
||
|
|
<span className="viewer-counter">
|
||
|
|
{currentIndex + 1} / {results.length}
|
||
|
|
</span>
|
||
|
|
<button className="viewer-btn" onClick={(e) => { e.stopPropagation(); handleDownload(); }}>
|
||
|
|
<Download size={24} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="viewer-content" onClick={e => e.stopPropagation()}>
|
||
|
|
<img src={results[currentIndex].url} alt="Full view" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="viewer-nav">
|
||
|
|
<button
|
||
|
|
className="nav-btn"
|
||
|
|
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
|
||
|
|
disabled={currentIndex === 0}
|
||
|
|
>
|
||
|
|
<ChevronLeft size={32} />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className="nav-btn"
|
||
|
|
onClick={(e) => { e.stopPropagation(); handleNext(); }}
|
||
|
|
disabled={currentIndex === results.length - 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>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|