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:
2026-05-17 00:46:02 +03:00
commit 1c7bb75a05
69 changed files with 12035 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import React from 'react'
export interface AppCardProps {
name: string
imageUrl: string
url: string
useProxy?: boolean
}
const AppCard: React.FC<AppCardProps> = ({ name, imageUrl, url, useProxy }) => {
const openApp = () => {
window.electron?.createView(name, url, imageUrl, 1.0, useProxy ?? false)
}
return (
<div className="app-card" onClick={openApp}>
{imageUrl ? (
<img src={imageUrl} alt={name} />
) : (
<div className="app-card-icon-placeholder">{name.charAt(0).toUpperCase()}</div>
)}
<h3>{name}</h3>
</div>
)
}
export default AppCard

View File

@@ -0,0 +1,37 @@
import React from 'react'
import AppCard, { AppCardProps } from './AppCard'
import BookmarksBar from './BookmarksBar'
import { Bookmark } from './Settings'
interface AppListProps {
apps: AppCardProps[]
bookmarks: Bookmark[]
onBookmarkOpen: (b: Bookmark) => void
onBookmarkRemove: (index: number) => void
}
const AppList: React.FC<AppListProps> = ({ apps, bookmarks, onBookmarkOpen, onBookmarkRemove }) => {
return (
<div className="app-list">
<BookmarksBar bookmarks={bookmarks} onOpen={onBookmarkOpen} onRemove={onBookmarkRemove} />
{apps.length === 0 ? (
<div className="app-list-empty">
<p>Нет приложений.</p>
<p>Откройте настройки (шестерёнка) и добавьте сайты.</p>
</div>
) : (
apps.map((card, i) => (
<AppCard
key={i}
name={card.name}
imageUrl={card.imageUrl}
url={card.url}
useProxy={card.useProxy}
/>
))
)}
</div>
)
}
export default AppList

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react'
import { Bookmark } from './Settings'
interface BookmarksBarProps {
bookmarks: Bookmark[]
onOpen: (b: Bookmark) => void
onRemove: (index: number) => void
}
const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove }) => {
const [expanded, setExpanded] = useState(false)
if (!bookmarks.length) return null
return (
<div className="bookmarks-bar">
<div className="bookmarks-bar-header" onClick={() => setExpanded(e => !e)}>
<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>
<span>Закладки ({bookmarks.length})</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{expanded ? <polyline points="18 15 12 9 6 15" /> : <polyline points="6 9 12 15 18 9" />}
</svg>
</div>
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
<div className="bookmarks-collapse-inner">
<div className="bookmarks-list">
{bookmarks.map((b, i) => {
const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
return (
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
<div className="bookmark-poster">
{b.poster
? <img src={b.poster} alt={b.title} onError={e => {
const t = e.currentTarget
t.style.display = 'none'
const ph = t.nextElementSibling as HTMLElement | null
if (ph) ph.style.display = 'flex'
}} />
: null}
<div className="bookmark-poster-placeholder" style={b.poster ? { display: 'none' } : undefined}>
{b.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="bookmark-info">
<div className="bookmark-title" title={b.title}>{b.title}</div>
{b.source && (
<div className="bookmark-source-row">
{hasMoviePoster && b.siteIcon && (
<img className="bookmark-source-icon" src={b.siteIcon} alt="" onError={e => { e.currentTarget.style.display = 'none' }} />
)}
<span className="bookmark-source">{b.source}</span>
</div>
)}
</div>
<button
className="bookmark-remove"
onClick={e => { e.stopPropagation(); onRemove(i) }}
title="Удалить закладку"
></button>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
export default BookmarksBar

344
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect, useRef } from 'react'
import Settings from './Settings'
import { AppEntry } from './Settings'
interface HeaderProps {
activeApp: string
setActiveApp: (name: string) => void
onAppsChange: (apps: AppEntry[]) => void
onMovieSearch: (query: string) => void
onBookmark: (title: string, url: string, poster: string, source: string, siteIcon?: string) => void
onBookmarkRemove: (index: number) => void
bookmarks: import('./Settings').Bookmark[]
openedFromSearch?: boolean
onBackToSearch?: () => void
}
const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange, onMovieSearch, onBookmark, onBookmarkRemove, bookmarks, openedFromSearch, onBackToSearch }) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [leftDisabled, setLeftDisabled] = useState(true)
const [rightDisabled, setRightDisabled] = useState(true)
const [refreshDisabled, setRefreshDisabled] = useState(true)
const [showSettings, setShowSettings] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeAppRef = useRef(activeApp)
useEffect(() => { activeAppRef.current = activeApp }, [activeApp])
useEffect(() => {
if (!window.electron) return
const offCloseApp = window.electron.on('closeApp', () => {
window.electron!.removeView(activeAppRef.current)
setIsCollapsed(false)
setActiveApp('home')
})
const offWebButtons = window.electron.on('updateWebButtons', (app: { historyPosition: number; history: string[] }) => {
setLeftDisabled(app.historyPosition === 0)
setRightDisabled(app.historyPosition === app.history.length - 1)
setRefreshDisabled(false)
setCurrentUrl(app.history[app.historyPosition] || '')
})
return () => { offCloseApp(); offWebButtons() }
}, [setActiveApp])
const closeCurrentApp = () => {
window.electron?.confirm('Закрыть приложение?', 'closeApp')
}
const openSettings = () => {
if (appOpen) window.electron?.hideView()
setShowSettings(true)
}
const closeSettings = () => {
setShowSettings(false)
if (appOpen) window.electron?.showView(activeApp)
}
const toggleCollapse = () => {
if (isCollapsed) {
setIsCollapsed(false)
setIsHovered(false)
window.electron?.expandWithHeader()
} else {
setIsCollapsed(true)
window.electron?.collapseWithHeader()
}
}
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (isCollapsed) {
setIsHovered(true)
window.electron?.expandWithHeader()
}
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (isCollapsed) {
setIsHovered(false)
window.electron?.collapseWithHeader()
}
}, 150)
}
const backwardPage = () => {
setLeftDisabled(true)
setRightDisabled(true)
setRefreshDisabled(true)
window.electron?.backwardPage()
}
const forwardPage = () => {
setLeftDisabled(true)
setRightDisabled(true)
setRefreshDisabled(true)
window.electron?.forwardPage()
}
const refreshPage = () => {
setRefreshDisabled(true)
window.electron?.refreshPage()
}
const appOpen = activeApp !== 'home' && activeApp !== 'movie-search'
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
const [isKiosk, setIsKiosk] = useState(true)
type UpdateStatus =
| { state: 'available'; version: string; currentVersion?: string }
| { state: 'downloading'; percent: number; bytesPerSecond?: number; transferred?: number; total?: number; version?: string; currentVersion?: string }
| { state: 'ready'; version: string; currentVersion?: string }
| { state: 'manual'; version: string; currentVersion?: string; installerUrl: string; installerName?: string }
| { state: 'error'; message: string }
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
useEffect(() => {
if (!window.electron) return
const off = window.electron.on('update-status', (info: UpdateStatus) => {
setUpdateStatus(prev => {
// Preserve version across state transitions when payload omits it (download-progress)
if (info.state === 'downloading' && prev && 'version' in prev && prev.version) {
return { ...info, version: info.version || prev.version, currentVersion: info.currentVersion || ('currentVersion' in prev ? prev.currentVersion : undefined) }
}
return info
})
})
return off
}, [])
useEffect(() => {
window.electron?.isKiosk().then(k => setIsKiosk(k))
}, [])
const toggleKiosk = () => {
window.electron?.toggleKiosk().then((newState: boolean) => setIsKiosk(newState))
}
const [isBookmarked, setIsBookmarked] = useState(false)
const handleBookmark = async () => {
const page = await window.electron?.getCurrentPage()
if (!page) return
let pageHost = ''
try { pageHost = new URL(page.url).hostname } catch (_) {}
// Match by full URL — different movies on same site must not collide.
const existingIdx = bookmarks.findIndex(b => b.url === page.url)
if (existingIdx !== -1) {
onBookmarkRemove(existingIdx)
setIsBookmarked(false)
return
}
// Pull og:image / JSON-LD poster from the live page (specific to this movie).
const meta = await window.electron?.getPageMeta?.().catch(() => null)
const poster = meta?.poster || ''
const title = (meta?.title || page.name || '').trim() || page.name
onBookmark(title, page.url, poster, pageHost, page.imageUrl || '')
setIsBookmarked(true)
}
useEffect(() => {
if (!appOpen || !currentUrl) { setIsBookmarked(false); return }
setIsBookmarked(bookmarks.some(b => b.url === currentUrl))
}, [currentUrl, bookmarks, appOpen])
return (
<>
<div
className={`header ${isCollapsed && !isHovered ? 'collapsed' : 'expanded'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{(!isCollapsed || isHovered) && (
<>
<div className="header-left">
<div className="header-btn" onClick={toggleKiosk} title={isKiosk ? 'Выйти из режима киоска' : 'Режим киоска'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isKiosk ? (
<>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</>
) : (
<>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</>
)}
</svg>
</div>
<div className="header-btn" onClick={openSettings} title="Настройки">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
</div>
<div className="header-center">
{appOpen && openedFromSearch && onBackToSearch && (
<button className="header-btn nav-btn" onClick={onBackToSearch} title="Вернуться к результатам поиска">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e53935" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<span style={{ fontSize: '12px', color: '#e53935', marginLeft: 2 }}>Поиск</span>
</button>
)}
{appOpen && (
<>
<button
className={`header-btn nav-btn${leftDisabled ? ' disabled' : ''}`}
onClick={leftDisabled ? undefined : backwardPage}
title="Назад"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button
className={`header-btn nav-btn${rightDisabled ? ' disabled' : ''}`}
onClick={rightDisabled ? undefined : forwardPage}
title="Вперёд"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button
className={`header-btn nav-btn${refreshDisabled ? ' disabled' : ''}`}
onClick={refreshDisabled ? undefined : refreshPage}
title="Обновить"
>
<svg width="20" height="20" 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>
<button className="header-btn nav-btn" onClick={handleBookmark} title={isBookmarked ? 'Удалить закладку' : 'В закладки'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill={isBookmarked ? '#f5c518' : 'none'} stroke={isBookmarked ? '#f5c518' : 'currentColor'} strokeWidth="2.5" 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>
<button
className="header-btn nav-btn"
onClick={toggleCollapse}
title={isCollapsed ? 'Развернуть шапку' : 'Свернуть шапку'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{isCollapsed
? <polyline points="6 9 12 15 18 9" />
: <polyline points="18 15 12 9 6 15" />
}
</svg>
</button>
</>
)}
</div>
<div className="header-right">
{showSearchIcon && (
<button
className={`header-btn nav-btn${activeApp === 'movie-search' ? ' active' : ''}`}
onClick={() => onMovieSearch('')}
title="Поиск фильмов"
>
<svg width="18" height="18" 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>
)}
{appOpen && (
<button className="header-btn header-close-btn" onClick={closeCurrentApp} title="Закрыть приложение">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</>
)}
</div>
{updateStatus && (
<div className={`update-banner${updateStatus.state === 'error' ? ' error' : ''}`}>
{updateStatus.state === 'available' && (
<>
<span className="update-banner-spinner" />
<span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
</>
)}
{updateStatus.state === 'error' && (
<>
<span>Ошибка обновления: {updateStatus.message}</span>
<button className="update-banner-btn" onClick={() => window.electron?.checkUpdateNow?.()}>
Повторить
</button>
</>
)}
{updateStatus.state === 'downloading' && (
<>
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>
<div className="update-banner-progress">
<div className="update-banner-progress-bar" style={{ width: `${updateStatus.percent}%` }} />
</div>
</>
)}
{updateStatus.state === 'ready' && (
<>
<span>Версия {updateStatus.version} готова к установке</span>
<button className="update-banner-btn" onClick={() => window.electron?.installUpdate?.()}>
Установить и перезапустить
</button>
</>
)}
{updateStatus.state === 'manual' && (
<>
<span>Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateStatus.installerUrl, '', 1.0, false)}>
Скачать установщик
</button>
</>
)}
<button className="update-banner-close" onClick={() => setUpdateStatus(null)}></button>
</div>
)}
{showSettings && (
<Settings onClose={closeSettings} onAppsChange={onAppsChange} />
)}
</>
)
}
export default Header

View 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

354
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react'
export interface AppEntry {
name: string
imageUrl: string
url: string
useProxy: boolean
}
export interface Bookmark {
title: string
url: string
poster?: string // movie poster (og:image, or fallback site icon)
source?: string // domain shown under title
siteIcon?: string // small site icon shown alongside source
}
export interface MovieSite {
domain: string
type: 'dle' | 'hdrezka' | 'filmix'
enabled: boolean
}
interface ProxyConfig {
host: string
port: string
}
interface SettingsData {
apps: AppEntry[]
proxy: ProxyConfig
movieSites: MovieSite[]
tmdbApiKey: string
bookmarks: Bookmark[]
trustedDomains?: string[]
}
interface SettingsProps {
onClose: () => void
onAppsChange: (apps: AppEntry[]) => void
}
const DEFAULT_TRUSTED_DOMAINS = [
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com',
'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net',
'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com',
'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru',
'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com',
'apple.com', 'icloud.com', 'facebook.com', 'fb.com',
]
const DEFAULT_SETTINGS: SettingsData = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, movieSites: [], tmdbApiKey: '', bookmarks: [], trustedDomains: DEFAULT_TRUSTED_DOMAINS }
function guessMovieSiteType(domain: string): MovieSite['type'] {
if (/rezka/.test(domain)) return 'hdrezka'
if (/filmix/.test(domain)) return 'filmix'
return 'dle'
}
function saveSettings(data: SettingsData) {
window.electron?.writeConfig(data)
}
const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
const [newApp, setNewApp] = useState<AppEntry>({ name: '', imageUrl: '', url: '', useProxy: false })
useEffect(() => {
window.electron?.readConfig().then((cfg: SettingsData | null) => {
if (cfg?.apps) setSettings(cfg)
})
}, [])
const updateProxy = (field: keyof ProxyConfig, value: string) => {
const updated = { ...settings, proxy: { ...settings.proxy, [field]: value } }
setSettings(updated)
saveSettings(updated)
window.electron?.setProxy(updated.proxy.host, updated.proxy.port)
}
const toggleAppProxy = (index: number) => {
const apps = settings.apps.map((app, i) =>
i === index ? { ...app, useProxy: !app.useProxy } : app
)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
const addApp = () => {
if (!newApp.name || !newApp.url) return
const apps = [...settings.apps, newApp]
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
setNewApp({ name: '', imageUrl: '', url: '', useProxy: false })
}
const removeApp = (index: number) => {
const apps = settings.apps.filter((_, i) => i !== index)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
const [newSite, setNewSite] = useState<MovieSite>({ domain: '', type: 'dle', enabled: true })
const [newTrusted, setNewTrusted] = useState('')
const trustedDomains = settings.trustedDomains ?? DEFAULT_TRUSTED_DOMAINS
const addTrustedDomain = () => {
const d = newTrusted.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\./, '')
if (!d || trustedDomains.includes(d)) { setNewTrusted(''); return }
const updated = { ...settings, trustedDomains: [...trustedDomains, d] }
setSettings(updated); saveSettings(updated); setNewTrusted('')
}
const removeTrustedDomain = (index: number) => {
const updated = { ...settings, trustedDomains: trustedDomains.filter((_, i) => i !== index) }
setSettings(updated); saveSettings(updated)
}
const resetTrustedDomains = () => {
const updated = { ...settings, trustedDomains: DEFAULT_TRUSTED_DOMAINS }
setSettings(updated); saveSettings(updated)
}
const addMovieSite = () => {
if (!newSite.domain.trim()) return
let domain = newSite.domain.trim()
if (domain.startsWith('https://')) domain = domain.slice(8)
else if (domain.startsWith('http://')) domain = domain.slice(7)
if (domain.endsWith('/')) domain = domain.slice(0, -1)
const movieSites = [...(settings.movieSites ?? []), { ...newSite, domain }]
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
setNewSite({ domain: '', type: 'dle', enabled: true })
}
const removeMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).filter((_, i) => i !== index)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
const toggleMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).map((s, i) =>
i === index ? { ...s, enabled: !s.enabled } : s
)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={e => e.stopPropagation()}>
<div className="settings-header">
<h2>Настройки</h2>
<button className="settings-close" onClick={onClose}></button>
</div>
<div className="settings-section">
<h3>Прокси</h3>
<div className="proxy-row">
<input
className="settings-input"
placeholder="Хост"
value={settings.proxy.host}
onChange={e => updateProxy('host', e.target.value)}
/>
<span className="proxy-colon">:</span>
<input
className="settings-input proxy-port"
placeholder="Порт"
value={settings.proxy.port}
onChange={e => updateProxy('port', e.target.value)}
/>
</div>
<div className="proxy-info">
<code>http_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>https_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>socks5://{settings.proxy.host}:{settings.proxy.port}</code>
</div>
<p className="proxy-hint">
Прокси применяется к каждому сайту индивидуально переключатель рядом с каждым приложением.
</p>
</div>
<div className="settings-section">
<div className="settings-section-head-row">
<h3>Доверенные домены</h3>
<button className="settings-reset-btn" onClick={resetTrustedDomains} title="Сбросить к стандартному списку">Сбросить</button>
</div>
<p className="proxy-hint">
Переходы и popup'ы на эти домены открываются без подтверждения — нужно для входа через Google, Яндекс, GitHub и т.п.
Совпадение по суффиксу: <code>google.com</code> разрешит и <code>accounts.google.com</code>, и <code>www.google.com</code>.
</p>
<div className="trusted-domains-list">
{trustedDomains.map((d, i) => (
<span key={i} className="trusted-domain-chip">
{d}
<button className="trusted-domain-remove" onClick={() => removeTrustedDomain(i)} title="Удалить">✕</button>
</span>
))}
{trustedDomains.length === 0 && <p className="settings-empty">Список пуст.</p>}
</div>
<div className="trusted-domain-add">
<input
className="settings-input"
placeholder="example.com"
value={newTrusted}
onChange={e => setNewTrusted(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTrustedDomain()}
/>
<button className="settings-add-btn" onClick={addTrustedDomain}>Добавить</button>
</div>
</div>
<div className="settings-section">
<h3>Приложения</h3>
<div className="settings-apps-list">
{settings.apps.map((app, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
{app.imageUrl && (
<img src={app.imageUrl} alt={app.name} className="settings-app-icon" />
)}
<div className="settings-app-text">
<span className="settings-app-name">{app.name}</span>
<span className="settings-app-url">{app.url}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Прокси</span>
<div
className={`proxy-switch ${app.useProxy ? 'on' : 'off'}`}
onClick={() => toggleAppProxy(i)}
/>
</label>
<button className="settings-remove-btn" onClick={() => removeApp(i)}>✕</button>
</div>
</div>
))}
{settings.apps.length === 0 && (
<p className="settings-empty">Нет приложений. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить приложение</h4>
<input
className="settings-input"
placeholder="Название"
value={newApp.name}
onChange={e => setNewApp({ ...newApp, name: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL сайта"
value={newApp.url}
onChange={e => setNewApp({ ...newApp, url: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL иконки (необязательно)"
value={newApp.imageUrl}
onChange={e => setNewApp({ ...newApp, imageUrl: e.target.value })}
/>
<label className="proxy-switch-label add-proxy-label">
<span>Использовать прокси</span>
<div
className={`proxy-switch ${newApp.useProxy ? 'on' : 'off'}`}
onClick={() => setNewApp({ ...newApp, useProxy: !newApp.useProxy })}
/>
</label>
<button className="settings-add-btn" onClick={addApp}>Добавить</button>
</div>
</div>
<div className="settings-section">
<h3>Поиск фильмов</h3>
<h4>TMDB API ключ</h4>
<input
className="settings-input"
placeholder="Получить бесплатно на themoviedb.org"
value={settings.tmdbApiKey ?? ''}
onChange={e => {
const updated = { ...settings, tmdbApiKey: e.target.value }
setSettings(updated)
saveSettings(updated)
}}
/>
<p className="proxy-hint">Нужен для постеров и метаданных. Без ключа поиск работает напрямую по сайтам.</p>
<h4>Сайты</h4>
<p className="proxy-hint">Тип определяется автоматически. Поддерживаются: kinogo, lordfilm, gidonline, hdrezka, filmix и их зеркала. Домен без https://</p>
<div className="settings-apps-list">
{(settings.movieSites ?? []).map((site, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
<div className="settings-app-text">
<span className="settings-app-name">{site.domain}</span>
<span className="settings-app-url">{site.type}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Вкл</span>
<div className={`proxy-switch ${site.enabled ? 'on' : 'off'}`} onClick={() => toggleMovieSite(i)} />
</label>
<button className="settings-remove-btn" onClick={() => removeMovieSite(i)}>✕</button>
</div>
</div>
))}
{!(settings.movieSites ?? []).length && (
<p className="settings-empty">Нет сайтов. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить сайт</h4>
<input
className="settings-input"
placeholder="Домен (например: kinogo.cc)"
value={newSite.domain}
onChange={e => {
const domain = e.target.value
setNewSite({ ...newSite, domain, type: guessMovieSiteType(domain) })
}}
/>
<select
className="settings-select"
value={newSite.type}
onChange={e => setNewSite({ ...newSite, type: e.target.value as MovieSite['type'] })}
>
<option value="dle">DLE (kinogo, lordfilm и зеркала)</option>
<option value="hdrezka">HDRezka</option>
<option value="filmix">Filmix</option>
</select>
<button className="settings-add-btn" onClick={addMovieSite}>Добавить</button>
</div>
</div>
</div>
</div>
)
}
export default Settings

View File

@@ -0,0 +1,72 @@
import React, { useState, useRef, useEffect } from 'react'
import SidebarElement, { SidebarElementProps } from './SidebarElement'
interface SidebarProps {
openedApps: SidebarElementProps[]
activeApp: string
setActiveApp: (name: string) => void
}
const Sidebar: React.FC<SidebarProps> = ({ openedApps, activeApp, setActiveApp }) => {
const [expanded, setExpanded] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const openedAppsCount = openedApps.length
useEffect(() => {
if (openedAppsCount === 0 && activeApp !== 'movie-search') setActiveApp('home')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedAppsCount, setActiveApp])
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(true)
window.electron?.adjustView(true)
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(false)
window.electron?.adjustView(false)
}, 150)
}
const goHome = () => {
window.electron?.hideView()
setActiveApp('home')
}
return (
<div
className={`sidebar ${expanded ? 'expanded' : 'collapsed'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={`sidebar-item ${activeApp === 'home' ? 'active' : ''}`}
onClick={goHome}
>
<svg className="sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>Домой</span>
</div>
{openedApps.map(app => (
<SidebarElement
key={app.name}
name={app.name}
imageUrl={app.imageUrl}
url={app.url}
isActive={app.isActive}
onClick={app.onClick}
/>
))}
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,26 @@
import React from 'react'
export interface SidebarElementProps {
name: string
imageUrl: string
url: string
isActive: boolean
onClick: () => void
}
const SidebarElement: React.FC<SidebarElementProps> = ({ name, imageUrl, isActive, onClick }) => {
return (
<div className={`sidebar-item ${isActive ? 'active' : ''}`} onClick={onClick}>
{imageUrl ? (
<img src={imageUrl} alt={name} className="sidebar-app-icon" />
) : (
<div className="sidebar-app-icon sidebar-app-icon-placeholder">
{name.charAt(0).toUpperCase()}
</div>
)}
<span>{name}</span>
</div>
)
}
export default SidebarElement