feat(1.0.14): тематики searchable, фикс лага confirm-диалога

- Confirm dialog: предзагрузка WebContentsView при старте приложения.
  Раньше каждое нажатие "Закрыть" создавало новый view с холодной
  загрузкой HTML+React → ~2с лаг и дубликаты от повторных кликов.
  Теперь view кэшируется, текст обновляется через IPC, повторные
  клики игнорируются пока диалог открыт.
- Темы: 14 → 71 (Война, Холодная война, Вьетнам, Призраки, Драконы,
  Шахматы, Самолёты, Поезда, Сёрфинг, Япония, ...). Все ID
  провалидированы probe-скриптом (≥50 фильмов на тематику).
- Chip-row заменён на SearchableSelect с поиском по подстроке —
  длинный список не помещается в чипы, а dropdown с фильтром
  гораздо удобнее. Заодно ушёл фиолетовый цвет чипа, плохо
  сочетавшийся с темой сайта.
This commit is contained in:
2026-05-17 11:29:39 +03:00
parent 8684eb7b67
commit 747b0f4c18
5 changed files with 265 additions and 45 deletions

58
main.js
View File

@@ -453,6 +453,10 @@ async function createWindow() {
} else { } else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
} }
// Прогрев confirm-диалога: создаём WebContentsView один раз, чтобы убрать ~2с лаг
// при первом нажатии кнопки закрытия (и кнопки навигации с подтверждением).
preloadConfirmView();
} }
// --- View helpers --- // --- View helpers ---
@@ -544,22 +548,56 @@ function removeError() {
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
} }
function setConfirm(text, actionOnYes) { // Confirm dialog: один кэшированный WebContentsView, переиспользуем для всех confirm'ов.
const view = makeDialogView(); // Холодный старт WebContentsView с React-загрузкой занимает ~1-2с, отсюда был лаг
confirmViews.push({ view, actionOnYes }); // при нажатии на кнопку закрытия. Теперь view создаётся при старте приложения и хранится готовым.
const query = new URLSearchParams({ text: text || '' }).toString(); let confirmCachedView = null;
view.webContents.once('did-finish-load', () => { addChild(view); }); let confirmReady = false;
let activeConfirm = null; // { actionOnYes } если диалог открыт, иначе null
let pendingConfirm = null; // если showConfirm вызвали до того как view загрузился
function preloadConfirmView() {
if (confirmCachedView) return;
confirmCachedView = makeDialogView();
confirmCachedView.webContents.once('did-finish-load', () => {
confirmReady = true;
if (pendingConfirm) {
const p = pendingConfirm;
pendingConfirm = null;
setConfirm(p.text, p.actionOnYes);
}
});
if (isDev) { if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html?${query}`); confirmCachedView.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html`);
} else { } else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'), { query: { text: text || '' } }); confirmCachedView.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'));
} }
} }
function setConfirm(text, actionOnYes) {
// Гард: если уже открыт диалог — игнорируем повторные клики (никаких дубликатов).
if (activeConfirm) return;
if (!confirmReady) {
pendingConfirm = { text, actionOnYes };
return;
}
activeConfirm = { actionOnYes };
// confirmViews — для backwards compat с обработчиком action; держим в синхроне.
confirmViews.push({ view: confirmCachedView, actionOnYes });
confirmCachedView.webContents.send('dialog-confirm-set', { text, visible: true });
addChild(confirmCachedView);
}
function removeConfirm() { function removeConfirm() {
if (!confirmViews.length) return; if (!activeConfirm) return;
const { view } = confirmViews.pop(); activeConfirm = null;
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); confirmViews.pop();
confirmCachedView.webContents.send('dialog-confirm-set', { visible: false });
// Жду fade-out (≈220ms по dialogs.css), потом снимаю с DOM. View остаётся живой для следующего раза.
setTimeout(() => {
if (activeConfirm) return; // если за это время открыли новый — оставить
try { removeChild(confirmCachedView); } catch (_) {}
}, 250);
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.13", "version": "1.0.14",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@@ -45,6 +45,81 @@ const Select: React.FC<{
) )
} }
// Searchable single-select dropdown — для длинных списков (тематик).
const SearchableSelect: React.FC<{
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
placeholder: string
maxHeight?: number
}> = ({ value, onChange, options, placeholder, maxHeight = 320 }) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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); setSearch('') }
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => { if (open) requestAnimationFrame(() => inputRef.current?.focus()) }, [open])
const q = search.trim().toLowerCase()
const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options
return (
<div ref={ref} className={`ms-select ms-searchable${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={selected ? 'ms-select-active' : 'ms-select-placeholder'}>
{selected ? selected.label : placeholder}
</span>
{selected && (
<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-searchable-dropdown" onClick={e => e.stopPropagation()}>
<div className="ms-searchable-input-wrap">
<svg className="ms-searchable-icon" width="12" height="12" 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>
<input
ref={inputRef}
className="ms-searchable-input"
placeholder="Найти тему..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="ms-searchable-list" style={{ maxHeight }}>
{filtered.map(o => (
<div
key={o.value}
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setSearch('') }}
>{o.label}</div>
))}
{!filtered.length && <div className="ms-searchable-empty">Ничего не найдено</div>}
</div>
</div>
)}
</div>
)
}
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder. // Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
const MultiSelect: React.FC<{ const MultiSelect: React.FC<{
values: string[] values: string[]
@@ -153,23 +228,83 @@ const TV_GENRES = [
] ]
// Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB. // Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB.
// extraGenre добавляется AND-связкой к выбранным жанрам, если тематика этого требует // extraGenre добавляется AND-связкой к выбранным жанрам (только в movie-режиме),
// (например, "Музыка" без жанра 10402 даёт хорроры со скрипачами в кадре). // если тематика требует фильтра по жанру для чистоты выдачи (например, "Музыка"
// без жанра 10402 даёт хорроры со скрипачами в кадре).
// IDs провалидированы probe-скриптом (/search/keyword + /discover/movie):
// каждая тема даёт ≥50 результатов на первой странице популярных.
const THEMES: { id: string; label: string; keywordIds: number[]; extraGenre?: number }[] = [ const THEMES: { id: string; label: string; keywordIds: number[]; extraGenre?: number }[] = [
{ id: 'war', label: 'Война', keywordIds: [1956, 273967] },
{ id: 'coldwar', label: 'Холодная война', keywordIds: [2106] },
{ id: 'vietnam', label: 'Вьетнамская война', keywordIds: [2957] },
{ id: 'zombie', label: 'Зомби', keywordIds: [12377] }, { id: 'zombie', label: 'Зомби', keywordIds: [12377] },
{ id: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] },
{ id: 'vampire', label: 'Вампиры', keywordIds: [3133] }, { id: 'vampire', label: 'Вампиры', keywordIds: [3133] },
{ id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 }, { id: 'ghost', label: 'Призраки', keywordIds: [162846] },
{ id: 'witch', label: 'Ведьмы', keywordIds: [616] },
{ id: 'magic', label: 'Маги и волшебство', keywordIds: [2343, 177912] },
{ id: 'dragon', label: 'Драконы', keywordIds: [12554] },
{ id: 'fairytale', label: 'Сказки', keywordIds: [3205] },
{ id: 'cannibal', label: 'Каннибалы', keywordIds: [278235, 14895] },
{ id: 'cult', label: 'Секты', keywordIds: [6158] },
{ id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] },
{ id: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] },
{ id: 'alien', label: 'Инопланетяне', keywordIds: [9951] },
{ id: 'timetravel', label: 'Путешествия во времени', keywordIds: [4379] }, { id: 'timetravel', label: 'Путешествия во времени', keywordIds: [4379] },
{ id: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] }, { id: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] },
{ id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] }, { id: 'dystopia', label: 'Антиутопия', keywordIds: [4565, 350338] },
{ id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] }, { id: 'epidemic', label: 'Эпидемия и вирус', keywordIds: [188973, 17995, 188957] },
{ id: 'disaster', label: 'Стихийные бедствия', keywordIds: [5096, 10617] },
{ id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] }, { id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] },
{ id: 'alien', label: 'Инопланетяне', keywordIds: [9951] }, { id: 'robot', label: 'Роботы и ИИ', keywordIds: [14544, 803, 371846] },
{ id: 'fairytale', label: 'Сказки', keywordIds: [3205] }, { id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 },
{ id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] },
{ id: 'mafia', label: 'Мафия', keywordIds: [10391, 10291] }, { id: 'mafia', label: 'Мафия', keywordIds: [10391, 10291] },
{ id: 'gangster', label: 'Гангстеры', keywordIds: [3149] },
{ id: 'heist', label: 'Ограбления', keywordIds: [10051, 642] },
{ id: 'prison', label: 'Тюрьма', keywordIds: [378] },
{ id: 'escape', label: 'Побег из тюрьмы', keywordIds: [9777] },
{ id: 'police', label: 'Полиция и детективы', keywordIds: [703, 6149] },
{ id: 'court', label: 'Суд и адвокаты', keywordIds: [33519, 10909] },
{ id: 'journalism', label: 'Журналисты', keywordIds: [736, 917] },
{ id: 'doctor', label: 'Врачи и медицина', keywordIds: [11612, 13005] },
{ id: 'psych', label: 'Психбольница', keywordIds: [11857, 10323] },
{ id: 'school', label: 'Школа', keywordIds: [6270] },
{ id: 'college', label: 'Колледж и студенты', keywordIds: [3616] },
{ id: 'martial', label: 'Боевые искусства', keywordIds: [779, 780], extraGenre: 28 }, { id: 'martial', label: 'Боевые искусства', keywordIds: [779, 780], extraGenre: 28 },
{ id: 'samurai', label: 'Самураи', keywordIds: [1462] },
{ id: 'ninja', label: 'Ниндзя', keywordIds: [10278] },
{ id: 'boxing', label: 'Бокс', keywordIds: [209476] },
{ id: 'football', label: 'Футбол', keywordIds: [13042, 352822] },
{ id: 'sport', label: 'Спорт (общий)', keywordIds: [333328] },
{ id: 'olympics', label: 'Олимпиада', keywordIds: [315138] },
{ id: 'racing', label: 'Гонки', keywordIds: [191279, 9666] },
{ id: 'skiing', label: 'Лыжи и сноуборд', keywordIds: [248915, 3522] },
{ id: 'mountains', label: 'Горы и альпинизм', keywordIds: [159212, 160177] },
{ id: 'surfing', label: 'Сёрфинг', keywordIds: [5349] },
{ id: 'chess', label: 'Шахматы', keywordIds: [316] },
{ id: 'submarine', label: 'Подводные лодки', keywordIds: [339] },
{ id: 'ship', label: 'Корабли и море', keywordIds: [3799, 191585] },
{ id: 'airplane', label: 'Самолёты и пилоты', keywordIds: [3800, 3203] },
{ id: 'train', label: 'Поезда', keywordIds: [13008] },
{ id: 'rome', label: 'Древний Рим', keywordIds: [5049, 1405] },
{ id: 'medieval', label: 'Средневековье', keywordIds: [355987, 161257] },
{ id: 'revolution', label: 'Революция', keywordIds: [2020] },
{ id: 'biography', label: 'Биография и реальные события', keywordIds: [5565, 9672] },
{ id: 'artist', label: 'Художники и писатели', keywordIds: [2679, 13028] },
{ id: 'music', label: 'Музыка и группы', keywordIds: [18001, 4048], extraGenre: 10402 }, { id: 'music', label: 'Музыка и группы', keywordIds: [18001, 4048], extraGenre: 10402 },
{ id: 'circus', label: 'Цирк', keywordIds: [291] },
{ id: 'cooking', label: 'Кулинария', keywordIds: [1918, 18293] },
{ id: 'wallstreet', label: 'Уолл-стрит и финансы', keywordIds: [5636, 177493] },
{ id: 'religion', label: 'Религия', keywordIds: [11001] },
{ id: 'afterlife', label: 'Жизнь после смерти', keywordIds: [6155, 5484] },
{ id: 'dream', label: 'Сны', keywordIds: [346773] },
{ id: 'love', label: 'О любви', keywordIds: [9673] },
{ id: 'wedding', label: 'Свадьба', keywordIds: [13027] },
{ id: 'friendship', label: 'Дружба', keywordIds: [6054] },
{ id: 'family', label: 'Семья', keywordIds: [10235] },
{ id: 'twins', label: 'Близнецы', keywordIds: [15016] },
{ id: 'africa', label: 'Африка', keywordIds: [409] },
{ id: 'japan', label: 'Япония', keywordIds: [233] },
] ]
const SORTS = [ const SORTS = [
@@ -529,21 +664,15 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
)} )}
</div> </div>
{/* Themes (single-select — одна тематика за раз) */} {/* Themes — searchable dropdown (71 тематика, длинный список) */}
{!isSearchMode && ( {!isSearchMode && (
<div className="ms-genres ms-themes"> <div className="ms-theme-row">
<button <SearchableSelect
className={`ms-genre-chip ms-theme-chip${themeId === '' ? ' active' : ''}`} value={themeId}
onClick={() => handleFilterChange(() => setThemeId(''))} onChange={v => handleFilterChange(() => setThemeId(v))}
title="Без тематики" options={THEMES.map(t => ({ value: t.id, label: t.label }))}
>Без темы</button> placeholder="Тематика (например: о войне, зомби, шахматы)"
{THEMES.map(t => ( />
<button
key={t.id}
className={`ms-genre-chip ms-theme-chip${themeId === t.id ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setThemeId(prev => prev === t.id ? '' : t.id))}
>{t.label}</button>
))}
</div> </div>
)} )}

View File

@@ -4,22 +4,42 @@ import '../styles/dialogs.css'
declare global { declare global {
interface Window { interface Window {
electron?: { handleAction: (action: string) => void } electron?: {
handleAction: (action: string) => void
on: (channel: string, fn: (...args: any[]) => void) => () => void
}
__dialogData?: { text?: string } __dialogData?: { text?: string }
} }
} }
const ConfirmDialog = () => { const ConfirmDialog = () => {
// Cached view re-uses этот компонент: текст и видимость приходят по IPC.
// initialText из URL — backwards compat (если кто-то откроет dialog-confirm.html напрямую).
const initialText = new URLSearchParams(window.location.search).get('text') || ''
const [text, setText] = useState(initialText)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true))) if (initialText) {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}
return window.electron?.on('dialog-confirm-set', (data: { text?: string; visible?: boolean }) => {
if (typeof data.text === 'string') setText(data.text)
if (typeof data.visible === 'boolean') {
if (data.visible) requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
else setVisible(false)
}
})
}, []) }, [])
useEffect(() => { useEffect(() => {
if (visible) document.body.classList.add('visible') if (visible) {
document.body.classList.remove('hiding')
document.body.classList.add('visible')
} else {
document.body.classList.remove('visible')
document.body.classList.add('hiding')
}
}, [visible]) }, [visible])
return ( return (

View File

@@ -788,13 +788,46 @@ body {
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; } .ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; }
.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; } .ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Темы — фиолетовая заливка в активном состоянии, чтобы не сливались с жанрами */ /* Theme dropdown row (заменил chip-row из v1.0.13 — 71 тематика, нужен поиск) */
.ms-themes { margin-bottom: 6px; } .ms-theme-row { margin-bottom: 4px; }
.ms-theme-chip { font-size: 11.5px; padding: 5px 13px; } .ms-theme-row .ms-searchable { min-width: 280px; max-width: 420px; }
.ms-theme-chip.active {
background: linear-gradient(135deg, #b66dff 0%, #6e4dff 100%); /* Searchable dropdown — поиск + список */
border-color: #8a5cff; .ms-searchable-dropdown {
color: #fff; display: flex;
flex-direction: column;
min-width: 280px;
padding: 0;
}
.ms-searchable-input-wrap {
position: relative;
border-bottom: 1px solid var(--border);
padding: 6px 10px 6px 30px;
}
.ms-searchable-icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: #777;
}
.ms-searchable-input {
width: 100%;
background: transparent;
border: none;
outline: none;
color: #eee;
font-size: 12px;
padding: 4px 0;
}
.ms-searchable-input::placeholder { color: #666; }
.ms-searchable-list { overflow-y: auto; }
.ms-searchable-empty {
padding: 12px;
text-align: center;
color: #666;
font-size: 11px;
font-style: italic;
} }
.ms-genres-hint { .ms-genres-hint {