ESH-Media v1.0.11 — kiosk media browser for elderly users
Electron-based kiosk desktop app: large-tile launcher for YouTube, RuTube, movie sites and Google services, designed for low-tech grandparent use. Features: - WebContentsView-per-app tabbed browsing with session persistence - per-app proxy routing (Clash/V2Ray friendly, useProxy flag) - cliqz-electron adblocker with whitelist for OAuth/integrity domains - TMDB-backed movie search across kinogo / hdrezka / filmix - bookmark posters auto-fetched from og:image / JSON-LD - electron-updater wired to Gitea releases API (latest.yml + .blockmap) - cross-domain navigation confirms via custom WebContentsView dialogs - kiosk window with hidden menu, Ctrl+Shift+I devtools shortcut - Trusted Types disabled engine-wide so adblocker scriptlets work on YouTube Google OAuth handling (the hard-won part): Google's anti-abuse JS rejects WebContentsView + custom session settings as "embedded browser". So accounts.google.com opens in a top-level BrowserWindow popup in a dedicated persist:google-login partition that we never call setProxy/setUserAgent on — it inherits Windows system proxy and the default Electron-tagged UA, both of which Google accepts. After login, .google.com/.youtube.com cookies migrate into the parent view's session and the view reloads to pick up the logged-in state. Session restore: only the last-active tab attaches to the window; other tabs load silently in the background and become instantly visible when the user clicks them in the sidebar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
629
src/components/MovieSearch.tsx
Normal file
629
src/components/MovieSearch.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { MovieSite } from './Settings'
|
||||
|
||||
const Select: React.FC<{
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: { value: string; label: string }[]
|
||||
placeholder?: string
|
||||
}> = ({ value, onChange, options, placeholder }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const selected = options.find(o => o.value === value)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`ms-select${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)}>
|
||||
<div className="ms-select-trigger">
|
||||
<span className={selected && selected.value ? 'ms-select-active' : 'ms-select-placeholder'}>
|
||||
{selected ? selected.label : (placeholder ?? '')}
|
||||
</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="ms-select-dropdown" onClick={e => e.stopPropagation()}>
|
||||
{options.map(o => (
|
||||
<div
|
||||
key={o.value}
|
||||
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
|
||||
onClick={() => { onChange(o.value); setOpen(false) }}
|
||||
>{o.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
|
||||
const MultiSelect: React.FC<{
|
||||
values: string[]
|
||||
onChange: (v: string[]) => void
|
||||
options: { value: string; label: string }[]
|
||||
placeholder: string
|
||||
maxHeight?: number
|
||||
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const toggle = (v: string) => {
|
||||
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
|
||||
}
|
||||
|
||||
const label = values.length === 0
|
||||
? placeholder
|
||||
: values.length === 1
|
||||
? (options.find(o => o.value === values[0])?.label ?? values[0])
|
||||
: `${placeholder}: ${values.length}`
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
|
||||
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
|
||||
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
|
||||
{values.length > 0 && (
|
||||
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
|
||||
{options.map(o => {
|
||||
const on = values.includes(o.value)
|
||||
return (
|
||||
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
|
||||
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}</span>
|
||||
{o.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TmdbMovie {
|
||||
id: number
|
||||
mediaType: 'movie' | 'tv'
|
||||
title: string
|
||||
originalTitle: string
|
||||
year: string
|
||||
poster: string
|
||||
overview: string
|
||||
rating: string
|
||||
}
|
||||
|
||||
interface SiteResult {
|
||||
title: string
|
||||
url: string
|
||||
poster?: string
|
||||
year?: string
|
||||
source: string
|
||||
}
|
||||
|
||||
interface MovieSearchProps {
|
||||
onOpenUrl: (name: string, url: string) => void
|
||||
onBookmark?: (title: string, url: string, poster: string, source: string) => void
|
||||
initialQuery?: string
|
||||
}
|
||||
|
||||
const MOVIE_GENRES = [
|
||||
{ id: 28, name: 'Боевик' }, { id: 12, name: 'Приключения' }, { id: 16, name: 'Мультфильм' },
|
||||
{ id: 35, name: 'Комедия' }, { id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' },
|
||||
{ id: 18, name: 'Драма' }, { id: 10751, name: 'Семейный' }, { id: 14, name: 'Фэнтези' },
|
||||
{ id: 36, name: 'История' }, { id: 27, name: 'Ужасы' }, { id: 9648, name: 'Детектив' },
|
||||
{ id: 10749, name: 'Мелодрама' }, { id: 878, name: 'Фантастика' }, { id: 53, name: 'Триллер' },
|
||||
{ id: 10752, name: 'Военный' }, { id: 37, name: 'Вестерн' },
|
||||
]
|
||||
|
||||
const TV_GENRES = [
|
||||
{ id: 10759, name: 'Боевик' }, { id: 16, name: 'Мультфильм' }, { id: 35, name: 'Комедия' },
|
||||
{ id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' }, { id: 18, name: 'Драма' },
|
||||
{ id: 10751, name: 'Семейный' }, { id: 10762, name: 'Детское' }, { id: 9648, name: 'Детектив' },
|
||||
{ id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, name: 'Вестерн' },
|
||||
]
|
||||
|
||||
const SORTS = [
|
||||
{ value: 'popularity.desc', label: 'Популярные' },
|
||||
{ value: 'vote_average.desc', label: 'По рейтингу' },
|
||||
{ value: 'release_date.desc', label: 'Новые' },
|
||||
{ value: 'revenue.desc', label: 'По сборам' },
|
||||
]
|
||||
|
||||
const RATINGS = [
|
||||
{ value: '', label: 'Любой' },
|
||||
{ value: '5', label: '5+' },
|
||||
{ value: '6', label: '6+' },
|
||||
{ value: '7', label: '7+' },
|
||||
{ value: '8', label: '8+' },
|
||||
{ value: '9', label: '9+' },
|
||||
]
|
||||
|
||||
const COUNTRIES = [
|
||||
{ value: '', label: 'Страна' },
|
||||
{ value: 'US', label: 'США' },
|
||||
{ value: 'RU', label: 'Россия' },
|
||||
{ value: 'GB', label: 'Великобритания' },
|
||||
{ value: 'FR', label: 'Франция' },
|
||||
{ value: 'DE', label: 'Германия' },
|
||||
{ value: 'IT', label: 'Италия' },
|
||||
{ value: 'ES', label: 'Испания' },
|
||||
{ value: 'JP', label: 'Япония' },
|
||||
{ value: 'KR', label: 'Южная Корея' },
|
||||
{ value: 'CN', label: 'Китай' },
|
||||
{ value: 'IN', label: 'Индия' },
|
||||
{ value: 'SE', label: 'Швеция' },
|
||||
{ value: 'DK', label: 'Дания' },
|
||||
{ value: 'TR', label: 'Турция' },
|
||||
]
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const YEARS = Array.from({ length: CURRENT_YEAR - 1899 }, (_, i) => CURRENT_YEAR + 1 - i)
|
||||
|
||||
const StarIcon = () => (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="#f5c518" stroke="none">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => (
|
||||
<div
|
||||
className="movie-result-card"
|
||||
style={{ animationDelay: `${Math.max(0, idx - baseIdx) * 30}ms` }}
|
||||
onClick={() => onSelect(movie)}
|
||||
>
|
||||
<div className="movie-result-poster">
|
||||
{movie.poster
|
||||
? <img src={movie.poster} alt={movie.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
|
||||
: null
|
||||
}
|
||||
<div className="movie-result-poster-placeholder" style={movie.poster ? { display: 'none' } : undefined}>
|
||||
{movie.title.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="movie-result-info">
|
||||
<span className="movie-result-title">{movie.title}</span>
|
||||
<div className="ms-card-meta">
|
||||
{movie.year && <span className="movie-result-year">{movie.year}</span>}
|
||||
{movie.rating && <span className="ms-card-rating"><StarIcon /> {movie.rating}</span>}
|
||||
</div>
|
||||
{movie.mediaType === 'tv' && <span className="ms-card-type">Сериал</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initialQuery = '' }) => {
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
|
||||
const [sortBy, setSortBy] = useState('popularity.desc')
|
||||
const [genreIds, setGenreIds] = useState<number[]>([])
|
||||
const [years, setYears] = useState<string[]>([])
|
||||
const [minRating, setMinRating] = useState('')
|
||||
const [countries, setCountries] = useState<string[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
|
||||
const [activeQuery, setActiveQuery] = useState(initialQuery) // committed query (on Enter)
|
||||
const [tmdbResults, setTmdbResults] = useState<TmdbMovie[]>([])
|
||||
const [selected, setSelected] = useState<TmdbMovie | null>(null)
|
||||
const [siteResults, setSiteResults] = useState<SiteResult[]>([])
|
||||
const [tmdbLoading, setTmdbLoading] = useState(false)
|
||||
const [sitesLoading, setSitesLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [cardBase, setCardBase] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [sites, setSites] = useState<MovieSite[]>([])
|
||||
const [configLoaded, setConfigLoaded] = useState(false)
|
||||
|
||||
const isSearchMode = activeQuery.trim().length > 0
|
||||
const genres = mediaType === 'tv' ? TV_GENRES : MOVIE_GENRES
|
||||
|
||||
useEffect(() => {
|
||||
window.electron?.readConfig().then((cfg: any) => {
|
||||
let enabled: MovieSite[] = (cfg?.movieSites ?? []).filter((s: MovieSite) => s.enabled !== false)
|
||||
const NON_MOVIE = /youtube|rutube|vk\.com|ok\.ru|google|yandex|mail\.ru|twitch|tiktok|instagram|facebook|twitter|telegram/i
|
||||
if (!enabled.length && cfg?.apps?.length) {
|
||||
enabled = cfg.apps
|
||||
.filter((app: any) => { try { return !NON_MOVIE.test(new URL(app.url).hostname) } catch { return false } })
|
||||
.map((app: any) => {
|
||||
let domain = app.url
|
||||
try { domain = new URL(app.url).hostname } catch (_) {}
|
||||
const type: MovieSite['type'] = /rezka/.test(domain) ? 'hdrezka' : /filmix/.test(domain) ? 'filmix' : 'dle'
|
||||
return { domain, type, enabled: true }
|
||||
})
|
||||
}
|
||||
const key: string = cfg?.tmdbApiKey ?? ''
|
||||
setSites(enabled)
|
||||
setApiKey(key)
|
||||
setConfigLoaded(true)
|
||||
if (initialQuery) {
|
||||
if (key) doTmdbSearch(initialQuery, key)
|
||||
else doSiteSearch(initialQuery, enabled, undefined, undefined)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-load discover when config ready or filters change (discover mode only)
|
||||
const discoverRef = useRef(0)
|
||||
const doDiscover = useCallback(async (key: string, pg: number, append: boolean) => {
|
||||
const token = ++discoverRef.current
|
||||
if (append) setLoadingMore(true)
|
||||
else { setTmdbLoading(true); setMessage('') }
|
||||
try {
|
||||
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
|
||||
if (token !== discoverRef.current) return
|
||||
if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
|
||||
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
|
||||
setTotalPages(res.totalPages)
|
||||
if (!res.results.length && !append) setMessage('Ничего не найдено')
|
||||
} catch {
|
||||
if (token === discoverRef.current) setMessage('Ошибка загрузки')
|
||||
} finally {
|
||||
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
}, [mediaType, sortBy, genreIds, years, minRating, countries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!configLoaded || !apiKey || isSearchMode) return
|
||||
setPage(1)
|
||||
setTmdbResults([])
|
||||
doDiscover(apiKey, 1, false)
|
||||
}, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
|
||||
|
||||
const searchRef = useRef(0)
|
||||
const doTmdbSearch = async (q: string, key: string) => {
|
||||
const token = ++searchRef.current
|
||||
setTmdbLoading(true)
|
||||
setMessage('')
|
||||
setTmdbResults([])
|
||||
setSelected(null)
|
||||
setSiteResults([])
|
||||
try {
|
||||
const res = await window.electron!.searchTmdb(q, key)
|
||||
if (token !== searchRef.current) return
|
||||
if (res.error) { setMessage(`Ошибка TMDB: ${res.error}`); return }
|
||||
setTmdbResults(res.results)
|
||||
setTotalPages(1)
|
||||
if (!res.results.length) setMessage('Ничего не найдено')
|
||||
} catch {
|
||||
if (token === searchRef.current) setMessage('Ошибка TMDB')
|
||||
} finally {
|
||||
if (token === searchRef.current) setTmdbLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doSiteSearch = async (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string) => {
|
||||
if (!sitesToSearch.length) { setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.'); return }
|
||||
setSitesLoading(true)
|
||||
setMessage('')
|
||||
setSiteResults([])
|
||||
try {
|
||||
const data = await window.electron!.searchMovies(q, sitesToSearch)
|
||||
let filtered = data
|
||||
if (yearHint) {
|
||||
const y = parseInt(yearHint)
|
||||
const isTv = mt === 'tv'
|
||||
const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5
|
||||
const normalizeTitle = (t: string) => t.toLowerCase().replace(/[^а-яёa-z0-9]/gi, ' ').replace(/\s+/g, ' ').trim()
|
||||
const groups = new Map<string, SiteResult[]>()
|
||||
for (const r of data) {
|
||||
const key = normalizeTitle(r.title)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(r)
|
||||
}
|
||||
const deduped: SiteResult[] = []
|
||||
for (const group of groups.values()) {
|
||||
const minDist = Math.min(...group.map(yearDist))
|
||||
deduped.push(...group.filter(r => yearDist(r) === minDist))
|
||||
}
|
||||
filtered = isTv
|
||||
? deduped.sort((a, b) => yearDist(a) - yearDist(b))
|
||||
: deduped.filter(r => !r.year || yearDist(r) <= 1).sort((a, b) => yearDist(a) - yearDist(b))
|
||||
}
|
||||
setSiteResults(filtered)
|
||||
if (!filtered.length) setMessage('Не найдено ни на одном сайте')
|
||||
} catch {
|
||||
setMessage('Ошибка поиска по сайтам')
|
||||
} finally {
|
||||
setSitesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const q = query.trim()
|
||||
if (!q) return
|
||||
setActiveQuery(q)
|
||||
if (apiKey) doTmdbSearch(q, apiKey)
|
||||
else doSiteSearch(q, sites, undefined, undefined)
|
||||
}
|
||||
|
||||
const handleSelectMovie = (movie: TmdbMovie) => {
|
||||
setSelected(movie)
|
||||
setSiteResults([])
|
||||
setMessage('')
|
||||
const searchTitle = movie.title || movie.originalTitle
|
||||
doSiteSearch(searchTitle, sites, movie.year, movie.mediaType)
|
||||
if (movie.originalTitle && movie.originalTitle !== movie.title) {
|
||||
window.electron!.searchMovies(movie.originalTitle, sites).then(extra => {
|
||||
setSiteResults(prev => {
|
||||
const existing = new Set(prev.map(r => r.url))
|
||||
return [...prev, ...extra.filter(r => !existing.has(r.url))]
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => { setSelected(null); setSiteResults([]); setMessage('') }
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setCardBase(tmdbResults.length)
|
||||
doDiscover(apiKey, nextPage, true)
|
||||
}
|
||||
|
||||
const handleFilterChange = (fn: () => void) => {
|
||||
setSelected(null)
|
||||
setSiteResults([])
|
||||
fn()
|
||||
}
|
||||
|
||||
const clearQuery = () => {
|
||||
setQuery('')
|
||||
setActiveQuery('')
|
||||
setTmdbResults([])
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
const loading = tmdbLoading || sitesLoading
|
||||
|
||||
|
||||
return (
|
||||
<div className="movie-search">
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="movie-search-bar">
|
||||
<input
|
||||
className="header-search-input movie-search-input"
|
||||
placeholder="Название фильма или сериала..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && !loading && handleSearch()}
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button className="ms-clear-btn" onClick={clearQuery} title="Очистить">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button className="header-search-btn movie-search-btn" onClick={handleSearch} disabled={loading}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters (only in discover mode with TMDB key) */}
|
||||
{apiKey && !selected && (
|
||||
<div className="ms-filters">
|
||||
<div className="ms-filter-row">
|
||||
{/* Type toggle */}
|
||||
<div className="ms-type-toggle">
|
||||
<button
|
||||
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
|
||||
>Фильмы</button>
|
||||
<button
|
||||
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
|
||||
>Сериалы</button>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
{!isSearchMode && (
|
||||
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
|
||||
)}
|
||||
|
||||
{/* Min rating (single, threshold) */}
|
||||
{!isSearchMode && (
|
||||
<Select
|
||||
value={minRating}
|
||||
onChange={v => handleFilterChange(() => setMinRating(v))}
|
||||
options={RATINGS.map(r => ({ value: r.value, label: r.label === 'Любой' ? 'Рейтинг' : r.label }))}
|
||||
placeholder="Рейтинг"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Years (multi, OR) */}
|
||||
<MultiSelect
|
||||
values={years}
|
||||
onChange={v => handleFilterChange(() => setYears(v))}
|
||||
options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
|
||||
placeholder="Год"
|
||||
/>
|
||||
|
||||
{/* Countries (multi, OR) */}
|
||||
{!isSearchMode && (
|
||||
<MultiSelect
|
||||
values={countries}
|
||||
onChange={v => handleFilterChange(() => setCountries(v))}
|
||||
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
|
||||
placeholder="Страна"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
|
||||
{!isSearchMode && (
|
||||
<div className="ms-genres">
|
||||
<button
|
||||
className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => setGenreIds([]))}
|
||||
title="Сбросить жанры"
|
||||
>Все</button>
|
||||
{genres.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() =>
|
||||
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
|
||||
)}
|
||||
>{g.name}</button>
|
||||
))}
|
||||
{genreIds.length > 1 && (
|
||||
<span className="ms-genres-hint">все выбранные одновременно</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <p className="movie-search-message">{message}</p>}
|
||||
|
||||
{/* Detail view */}
|
||||
{selected ? (
|
||||
<div className="ms-detail">
|
||||
{selected.poster && (
|
||||
<div className="ms-detail-bg">
|
||||
<img className="ms-detail-bg-img" src={selected.poster} alt="" />
|
||||
<div className="ms-detail-bg-gradient" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ms-detail-content">
|
||||
<button className="ms-back-btn" onClick={handleBack}>← Назад</button>
|
||||
<div className="ms-detail-card">
|
||||
{selected.poster
|
||||
? <img className="ms-detail-poster" src={selected.poster} alt={selected.title} />
|
||||
: <div className="ms-poster-placeholder">{selected.title.charAt(0)}</div>
|
||||
}
|
||||
<div className="ms-detail-info">
|
||||
<h2 className="ms-detail-title">{selected.title}</h2>
|
||||
{selected.originalTitle !== selected.title && (
|
||||
<p className="ms-detail-orig">{selected.originalTitle}</p>
|
||||
)}
|
||||
<div className="ms-detail-meta">
|
||||
{selected.year && <span>{selected.year}</span>}
|
||||
{selected.mediaType === 'tv' && <span>Сериал</span>}
|
||||
{selected.rating && <span><StarIcon /> {selected.rating}</span>}
|
||||
</div>
|
||||
{selected.overview && <p className="ms-detail-overview">{selected.overview}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-site-results">
|
||||
{sitesLoading && <p className="movie-search-message">Ищем на {sites.length} сайтах...</p>}
|
||||
{!sitesLoading && !siteResults.length && !message && <p className="movie-search-message">Поиск...</p>}
|
||||
{!sitesLoading && siteResults.length > 0 && (
|
||||
<div className="ms-sites-label">
|
||||
Найдено на сайтах
|
||||
<button className="ms-retry-btn" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)} title="Повторить поиск">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!sitesLoading && !siteResults.length && (
|
||||
<button className="ms-retry-btn ms-retry-standalone" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)}>
|
||||
Повторить поиск
|
||||
</button>
|
||||
)}
|
||||
{siteResults.map((r, i) => (
|
||||
<div key={i} className="ms-site-row" onClick={() => onOpenUrl(r.title, r.url)}>
|
||||
<span className="ms-site-source">{r.source}</span>
|
||||
<span className="ms-site-title">{r.title}</span>
|
||||
<div className="ms-site-actions">
|
||||
{onBookmark && (
|
||||
<button className="ms-bookmark-btn" title="В закладки" onClick={e => {
|
||||
e.stopPropagation()
|
||||
onBookmark(selected?.title || r.title, r.url, selected?.poster || '', r.source)
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span className="ms-site-open">Открыть →</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tmdbLoading && <p className="movie-search-message">{isSearchMode ? 'Поиск...' : 'Загрузка...'}</p>}
|
||||
|
||||
{tmdbResults.length > 0 && (
|
||||
<>
|
||||
<div className="movie-results">
|
||||
{tmdbResults.map((movie, i) => <MovieCard key={movie.id} movie={movie} idx={i} baseIdx={cardBase} onSelect={handleSelectMovie} />)}
|
||||
</div>
|
||||
{!isSearchMode && page < totalPages && (
|
||||
<button className="ms-load-more" onClick={handleLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Загрузка...' : 'Загрузить ещё'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Direct site results (no TMDB key) */}
|
||||
{siteResults.length > 0 && (
|
||||
<div className="movie-results">
|
||||
{siteResults.map((r, i) => (
|
||||
<div key={i} className="movie-result-card" onClick={() => onOpenUrl(r.title, r.url)}>
|
||||
<div className="movie-result-poster">
|
||||
{r.poster
|
||||
? <img src={r.poster} alt={r.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
|
||||
: null
|
||||
}
|
||||
<div className="movie-result-poster-placeholder" style={r.poster ? { display: 'none' } : undefined}>{r.title.charAt(0).toUpperCase()}</div>
|
||||
</div>
|
||||
<div className="movie-result-info">
|
||||
<span className="movie-result-title">{r.title}</span>
|
||||
{r.year && <span className="movie-result-year">{r.year}</span>}
|
||||
<span className="movie-result-source">{r.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MovieSearch
|
||||
Reference in New Issue
Block a user