Files
ESH-Media/src/components/Settings.tsx
eshmeshek 8684eb7b67 feat(1.0.13): тематики, стриминг поиска, авто-логотипы
- Тематики: 14 курированных категорий (Зомби, Космос, Вампиры,
  Постапокалипсис, Шпионы и т.д.) поверх жанров TMDB. discover-tmdb
  принимает themeKeywords (pipe-OR по with_keywords); для musical/
  superhero/martial добавлен extraGenre AND-связкой в movie-режиме.
- Стриминг поиска по сайтам: search-movies переведён с invoke→return
  на event-emit — карточки появляются по мере ответа каждого сайта,
  не ждут самого медленного. Спиннер виден до первого результата.
- Авто-детект логотипа сайта: поле "URL иконки" убрано из формы.
  Бэк IPC detect-logo пробует manifest.json → apple-touch-icon →
  link rel=icon ≥48px → JSON-LD → og:image → msapplication-TileImage
  → /favicon.ico (с проверкой content-type=image/*). Легаси-приложения
  без иконки догоняются тихо при открытии Settings.
2026-05-17 11:15:42 +03:00

384 lines
15 KiB
TypeScript
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 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 })
// Тихая авто-детекция логотипа: обновляет imageUrl в state+config когда сервер вернёт URL.
// Если детект ничего не нашёл — оставляем пустой imageUrl (тогда отобразится буква-заглушка).
const autoDetectLogo = (appUrl: string, onDetected: (logoUrl: string) => void) => {
if (!window.electron?.detectLogo) return
window.electron.detectLogo(appUrl).then(logoUrl => {
if (logoUrl) onDetected(logoUrl)
}).catch(() => {})
}
useEffect(() => {
window.electron?.readConfig().then((cfg: SettingsData | null) => {
if (!cfg?.apps) return
setSettings(cfg)
// Догоняем легаси-приложения без иконки: тихо парсим и обновляем конфиг.
const missing = cfg.apps.filter(a => !a.imageUrl && a.url)
if (!missing.length) return
missing.forEach(target => {
autoDetectLogo(target.url, logoUrl => {
setSettings(prev => {
const apps = prev.apps.map(a => a.url === target.url && !a.imageUrl ? { ...a, imageUrl: logoUrl } : a)
const updated = { ...prev, apps }
saveSettings(updated)
onAppsChange(apps)
return updated
})
})
})
})
}, [])
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 fresh: AppEntry = { ...newApp, imageUrl: '' }
const apps = [...settings.apps, fresh]
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
setNewApp({ name: '', imageUrl: '', url: '', useProxy: false })
// Тихо детектим логотип в фоне; UI получит иконку, когда сервер ответит.
autoDetectLogo(fresh.url, logoUrl => {
setSettings(prev => {
const nextApps = prev.apps.map(a => a.url === fresh.url && !a.imageUrl ? { ...a, imageUrl: logoUrl } : a)
const next = { ...prev, apps: nextApps }
saveSettings(next)
onAppsChange(nextApps)
return next
})
})
}
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 })}
/>
<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