diff --git a/main.js b/main.js index 38a5bec..ef9f9bb 100644 --- a/main.js +++ b/main.js @@ -453,6 +453,10 @@ async function createWindow() { } else { mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')); } + + // Прогрев confirm-диалога: создаём WebContentsView один раз, чтобы убрать ~2с лаг + // при первом нажатии кнопки закрытия (и кнопки навигации с подтверждением). + preloadConfirmView(); } // --- View helpers --- @@ -544,22 +548,56 @@ function removeError() { dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); } -function setConfirm(text, actionOnYes) { - const view = makeDialogView(); - confirmViews.push({ view, actionOnYes }); - const query = new URLSearchParams({ text: text || '' }).toString(); - view.webContents.once('did-finish-load', () => { addChild(view); }); +// Confirm dialog: один кэшированный WebContentsView, переиспользуем для всех confirm'ов. +// Холодный старт WebContentsView с React-загрузкой занимает ~1-2с, отсюда был лаг +// при нажатии на кнопку закрытия. Теперь view создаётся при старте приложения и хранится готовым. +let confirmCachedView = null; +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) { - view.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html?${query}`); + confirmCachedView.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html`); } 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() { - if (!confirmViews.length) return; - const { view } = confirmViews.pop(); - dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); + if (!activeConfirm) return; + activeConfirm = null; + 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); } diff --git a/package.json b/package.json index 8db2785..5627e7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ESH-Media", - "version": "1.0.13", + "version": "1.0.14", "private": true, "main": "main.js", "scripts": { diff --git a/src/components/MovieSearch.tsx b/src/components/MovieSearch.tsx index 6a7b63f..bcb0004 100644 --- a/src/components/MovieSearch.tsx +++ b/src/components/MovieSearch.tsx @@ -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(null) + const inputRef = useRef(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 ( +
+
setOpen(o => !o)}> + + {selected ? selected.label : placeholder} + + {selected && ( + + )} + + + +
+ {open && ( +
e.stopPropagation()}> +
+ + + + setSearch(e.target.value)} + /> +
+
+ {filtered.map(o => ( +
{ onChange(o.value); setOpen(false); setSearch('') }} + >{o.label}
+ ))} + {!filtered.length &&
Ничего не найдено
} +
+
+ )} +
+ ) +} + // Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder. const MultiSelect: React.FC<{ values: string[] @@ -153,23 +228,83 @@ const TV_GENRES = [ ] // Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB. -// extraGenre добавляется AND-связкой к выбранным жанрам, если тематика этого требует -// (например, "Музыка" без жанра 10402 даёт хорроры со скрипачами в кадре). +// extraGenre добавляется AND-связкой к выбранным жанрам (только в movie-режиме), +// если тематика требует фильтра по жанру для чистоты выдачи (например, "Музыка" +// без жанра 10402 даёт хорроры со скрипачами в кадре). +// IDs провалидированы probe-скриптом (/search/keyword + /discover/movie): +// каждая тема даёт ≥50 результатов на первой странице популярных. 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: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] }, { 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: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] }, - { id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] }, - { id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] }, + { id: 'dystopia', label: 'Антиутопия', keywordIds: [4565, 350338] }, + { id: 'epidemic', label: 'Эпидемия и вирус', keywordIds: [188973, 17995, 188957] }, + { id: 'disaster', label: 'Стихийные бедствия', keywordIds: [5096, 10617] }, { id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] }, - { id: 'alien', label: 'Инопланетяне', keywordIds: [9951] }, - { id: 'fairytale', label: 'Сказки', keywordIds: [3205] }, + { id: 'robot', label: 'Роботы и ИИ', keywordIds: [14544, 803, 371846] }, + { id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 }, + { id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] }, { 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: '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: '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 = [ @@ -529,21 +664,15 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia )} - {/* Themes (single-select — одна тематика за раз) */} + {/* Themes — searchable dropdown (71 тематика, длинный список) */} {!isSearchMode && ( -
- - {THEMES.map(t => ( - - ))} +
+ handleFilterChange(() => setThemeId(v))} + options={THEMES.map(t => ({ value: t.id, label: t.label }))} + placeholder="Тематика (например: о войне, зомби, шахматы)" + />
)} diff --git a/src/entries/dialog-confirm.tsx b/src/entries/dialog-confirm.tsx index 4eae6d0..d0dd8d1 100644 --- a/src/entries/dialog-confirm.tsx +++ b/src/entries/dialog-confirm.tsx @@ -4,22 +4,42 @@ import '../styles/dialogs.css' declare global { interface Window { - electron?: { handleAction: (action: string) => void } + electron?: { + handleAction: (action: string) => void + on: (channel: string, fn: (...args: any[]) => void) => () => void + } __dialogData?: { text?: string } } } 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 params = new URLSearchParams(window.location.search) - const text = params.get('text') || window.__dialogData?.text || '' 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(() => { - 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]) return ( diff --git a/src/styles/main.css b/src/styles/main.css index 0d919f1..444f750 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -788,13 +788,46 @@ body { .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-themes { margin-bottom: 6px; } -.ms-theme-chip { font-size: 11.5px; padding: 5px 13px; } -.ms-theme-chip.active { - background: linear-gradient(135deg, #b66dff 0%, #6e4dff 100%); - border-color: #8a5cff; - color: #fff; +/* Theme dropdown row (заменил chip-row из v1.0.13 — 71 тематика, нужен поиск) */ +.ms-theme-row { margin-bottom: 4px; } +.ms-theme-row .ms-searchable { min-width: 280px; max-width: 420px; } + +/* Searchable dropdown — поиск + список */ +.ms-searchable-dropdown { + 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 {