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>
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.claude/
|
||||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"DockerRun.DisableDockerrc": true
|
||||||
|
}
|
||||||
87
README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# ESH-Media
|
||||||
|
|
||||||
|
Десктопное приложение на Electron + React. Запускает веб-сервисы в отдельных WebContentsView, поиск и обзор фильмов через TMDB, встроенная блокировка рекламы.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- Electron 32
|
||||||
|
- React 18 + TypeScript
|
||||||
|
- Vite
|
||||||
|
- @cliqz/adblocker-electron
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows (installer + zip)
|
||||||
|
npm run build:win
|
||||||
|
|
||||||
|
# Linux (AppImage + deb)
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакты в папке `release/`.
|
||||||
|
|
||||||
|
> Linux-сборку нужно запускать на Linux-машине.
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
Настройки открываются кнопкой в левом верхнем углу приложения.
|
||||||
|
|
||||||
|
### Приложения
|
||||||
|
|
||||||
|
Список сайтов, которые отображаются на главном экране в виде карточек. Для каждого можно указать:
|
||||||
|
|
||||||
|
- **Название** — отображается под иконкой
|
||||||
|
- **URL** — адрес сайта, открывается в отдельном WebContentsView
|
||||||
|
- **URL иконки** — картинка для карточки (необязательно)
|
||||||
|
- **Прокси** — использовать ли прокси для этого сайта (переключатель включается отдельно для каждого)
|
||||||
|
|
||||||
|
### Прокси
|
||||||
|
|
||||||
|
Приложение поддерживает HTTP/HTTPS/SOCKS5 прокси. Настраивается в разделе "Прокси" — указываешь хост и порт. Прокси применяется не глобально, а поприложенно: для каждого сайта в списке есть отдельный переключатель. Это позволяет, например, открывать заблокированные сайты через прокси, а остальные — напрямую.
|
||||||
|
|
||||||
|
Конфигурация прокси сохраняется в файл `~/.ESH-Media.json` и применяется при следующем запуске автоматически.
|
||||||
|
|
||||||
|
### Поиск фильмов
|
||||||
|
|
||||||
|
- **TMDB API Key** — ключ для поиска метаданных, постеров и обзора по фильтрам. Получить бесплатно на [themoviedb.org](https://www.themoviedb.org/settings/api). Поддерживаются как обычные API-ключи, так и Bearer-токены.
|
||||||
|
- **Сайты** — список фильмовых сайтов, на которых будет производиться поиск после выбора фильма из TMDB. Поддерживаются движки DLE (kinogo, lordfilm и зеркала), HDRezka, Filmix. Тип определяется автоматически по домену.
|
||||||
|
|
||||||
|
Если раздел "Сайты" пустой, приложение попробует использовать подходящие сайты из раздела "Приложения".
|
||||||
|
|
||||||
|
## Конфиг
|
||||||
|
|
||||||
|
Хранится в домашней директории пользователя: `~/.ESH-Media.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apps": [...],
|
||||||
|
"proxy": { "host": "127.0.0.1", "port": "7890" },
|
||||||
|
"movieSites": [...],
|
||||||
|
"tmdbApiKey": "...",
|
||||||
|
"bookmarks": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
main.js — main process
|
||||||
|
preload.js — preload / IPC bridge
|
||||||
|
index.html — точка входа основного UI
|
||||||
|
loader.html — экран загрузки
|
||||||
|
dialog-error.html — диалог ошибки
|
||||||
|
dialog-confirm.html — диалог подтверждения
|
||||||
|
src/
|
||||||
|
entries/ — entry points для Vite (loader, dialogs)
|
||||||
|
components/ — React компоненты
|
||||||
|
pages/ — страницы
|
||||||
|
styles/ — стили
|
||||||
|
```
|
||||||
12
dialog-confirm.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Confirm</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/entries/dialog-confirm.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
dialog-error.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Error</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/entries/dialog-error.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ESH-Media</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
loader.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Loading</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/entries/loader.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6522
package-lock.json
generated
Normal file
75
package.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "ESH-Media",
|
||||||
|
"version": "1.0.11",
|
||||||
|
"private": true,
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"dev:electron": "node scripts/start-electron.js",
|
||||||
|
"dev": "concurrently \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"build:win": "npm run build:renderer && electron-builder --win",
|
||||||
|
"build:linux": "npm run build:renderer && electron-builder --linux",
|
||||||
|
"build": "npm run build:renderer && electron-builder --win --linux",
|
||||||
|
"dist": "electron-builder"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cliqz/adblocker-electron": "^1.34.0",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"electron-updater": "^6.8.3",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"electron": "^32.0.0",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"wait-on": "^8.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.ESH-Media",
|
||||||
|
"productName": "ESH-Media",
|
||||||
|
"asar": true,
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"package.json",
|
||||||
|
"extensions/**/*"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"icon": "public/favicon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"icon": "public/logo.png",
|
||||||
|
"category": "Utility"
|
||||||
|
},
|
||||||
|
"publish": [
|
||||||
|
{
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "https://gitea.esh-service.ru/public/ESH-Media/releases/download/latest/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
35
preload.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
createView: (name, url, imageUrl, zoom, useProxy) =>
|
||||||
|
ipcRenderer.send('create-view', name, url, imageUrl, zoom, useProxy),
|
||||||
|
confirm: (text, funcName) => ipcRenderer.send('confirm', text, funcName),
|
||||||
|
removeView: (name) => ipcRenderer.send('remove-view', name),
|
||||||
|
hideView: () => ipcRenderer.send('hide-view'),
|
||||||
|
showView: (name) => ipcRenderer.send('show-view', name),
|
||||||
|
adjustView: (expanded) => ipcRenderer.send('adjust-view', expanded),
|
||||||
|
on: (channel, func) => {
|
||||||
|
const listener = (_event, ...args) => func(...args);
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
|
},
|
||||||
|
handleAction: (action) => ipcRenderer.send('action', action),
|
||||||
|
setProxy: (host, port) => ipcRenderer.send('set-proxy', host, port),
|
||||||
|
expandWithHeader: () => ipcRenderer.send('expandWithHeader'),
|
||||||
|
collapseWithHeader: () => ipcRenderer.send('collapseWithHeader'),
|
||||||
|
backwardPage: () => ipcRenderer.send('backwardPage'),
|
||||||
|
forwardPage: () => ipcRenderer.send('forwardPage'),
|
||||||
|
refreshPage: () => ipcRenderer.send('refreshPage'),
|
||||||
|
getCurrentPage: () => ipcRenderer.invoke('get-current-page'),
|
||||||
|
getPageMeta: () => ipcRenderer.invoke('get-page-meta'),
|
||||||
|
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||||
|
checkUpdateNow: () => ipcRenderer.invoke('check-update-now'),
|
||||||
|
readConfig: () => ipcRenderer.invoke('read-config'),
|
||||||
|
writeConfig: (data) => ipcRenderer.send('write-config', data),
|
||||||
|
searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites),
|
||||||
|
searchTmdb: (query, apiKey) => ipcRenderer.invoke('search-tmdb', query, apiKey),
|
||||||
|
discoverTmdb: (params) => ipcRenderer.invoke('discover-tmdb', params),
|
||||||
|
toggleKiosk: () => ipcRenderer.invoke('toggle-kiosk'),
|
||||||
|
isKiosk: () => ipcRenderer.invoke('is-kiosk'),
|
||||||
|
|
||||||
|
});
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/images/RuTube.jpg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/images/VPN.png
Normal file
|
After Width: | Height: | Size: 972 B |
BIN
public/images/Youtube_logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
14
public/images/bluetooth-on.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 217.499 217.499" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M123.264,108.749l45.597-44.488c1.736-1.693,2.715-4.016,2.715-6.441s-0.979-4.748-2.715-6.441l-50.038-48.82
|
||||||
|
c-2.591-2.528-6.444-3.255-9.78-1.853c-3.336,1.406-5.505,4.674-5.505,8.294v80.504l-42.331-41.3
|
||||||
|
c-3.558-3.471-9.255-3.402-12.727,0.156c-3.471,3.558-3.401,9.256,0.157,12.727l48.851,47.663l-48.851,47.663
|
||||||
|
c-3.558,3.471-3.628,9.169-0.157,12.727s9.17,3.628,12.727,0.156l42.331-41.3v80.504c0,3.62,2.169,6.888,5.505,8.294
|
||||||
|
c1.128,0.476,2.315,0.706,3.493,0.706c2.305,0,4.572-0.886,6.287-2.559l50.038-48.82c1.736-1.693,2.715-4.016,2.715-6.441
|
||||||
|
s-0.979-4.748-2.715-6.441L123.264,108.749z M121.539,30.354l28.15,27.465l-28.15,27.465V30.354z M121.539,187.143v-54.93
|
||||||
|
l28.15,27.465L121.539,187.143z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
14
public/images/bluetooth.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 217.499 217.499" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M123.264,108.749l45.597-44.488c1.736-1.693,2.715-4.016,2.715-6.441s-0.979-4.748-2.715-6.441l-50.038-48.82
|
||||||
|
c-2.591-2.528-6.444-3.255-9.78-1.853c-3.336,1.406-5.505,4.674-5.505,8.294v80.504l-42.331-41.3
|
||||||
|
c-3.558-3.471-9.255-3.402-12.727,0.156c-3.471,3.558-3.401,9.256,0.157,12.727l48.851,47.663l-48.851,47.663
|
||||||
|
c-3.558,3.471-3.628,9.169-0.157,12.727s9.17,3.628,12.727,0.156l42.331-41.3v80.504c0,3.62,2.169,6.888,5.505,8.294
|
||||||
|
c1.128,0.476,2.315,0.706,3.493,0.706c2.305,0,4.572-0.886,6.287-2.559l50.038-48.82c1.736-1.693,2.715-4.016,2.715-6.441
|
||||||
|
s-0.979-4.748-2.715-6.441L123.264,108.749z M121.539,30.354l28.15,27.465l-28.15,27.465V30.354z M121.539,187.143v-54.93
|
||||||
|
l28.15,27.465L121.539,187.143z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/images/church.png
Normal file
|
After Width: | Height: | Size: 427 B |
BIN
public/images/collapse.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
public/images/expand.png
Normal file
|
After Width: | Height: | Size: 600 B |
BIN
public/images/home.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
public/images/home.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 61.53 61.51"><defs><style>.cls-1{fill:#fff;}</style></defs><title>home</title><polygon class="cls-1" points="58.74 25.2 58.74 61.51 37.57 61.51 37.57 39.63 23.97 39.63 23.97 61.51 2.8 61.51 2.8 25.2 30.77 5.57 58.74 25.2"/><polyline class="cls-1" points="30.77 4.43 0 26.04 0 21.6 30.77 0"/><polyline class="cls-1" points="30.77 4.43 61.53 26.04 61.53 21.6 30.77 0"/></svg>
|
||||||
|
After Width: | Height: | Size: 465 B |
BIN
public/images/ivi.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/images/kinogo.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/images/kinopoisk.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/images/left-disabled.png
Normal file
|
After Width: | Height: | Size: 342 B |
BIN
public/images/left.png
Normal file
|
After Width: | Height: | Size: 341 B |
BIN
public/images/refresh-disabled.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/images/refresh.png
Normal file
|
After Width: | Height: | Size: 848 B |
BIN
public/images/right-disabled.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
public/images/right.png
Normal file
|
After Width: | Height: | Size: 350 B |
BIN
public/images/tv.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
9
public/images/volume-high.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>ionicons-v5-g</title>
|
||||||
|
<path d="M232,416a23.88,23.88,0,0,1-14.2-4.68,8.27,8.27,0,0,1-.66-.51L125.76,336H56a24,24,0,0,1-24-24V200a24,24,0,0,1,24-24h69.75l91.37-74.81a8.27,8.27,0,0,1,.66-.51A24,24,0,0,1,256,120V392a24,24,0,0,1-24,24ZM125.82,336Zm-.27-159.86Z"/>
|
||||||
|
<path d="M320,336a16,16,0,0,1-14.29-23.19c9.49-18.87,14.3-38,14.3-56.81,0-19.38-4.66-37.94-14.25-56.73a16,16,0,0,1,28.5-14.54C346.19,208.12,352,231.44,352,256c0,23.86-6,47.81-17.7,71.19A16,16,0,0,1,320,336Z"/>
|
||||||
|
<path d="M368,384a16,16,0,0,1-13.86-24C373.05,327.09,384,299.51,384,256c0-44.17-10.93-71.56-29.82-103.94a16,16,0,0,1,27.64-16.12C402.92,172.11,416,204.81,416,256c0,50.43-13.06,83.29-34.13,120A16,16,0,0,1,368,384Z"/>
|
||||||
|
<path d="M416,432a16,16,0,0,1-13.39-24.74C429.85,365.47,448,323.76,448,256c0-66.5-18.18-108.62-45.49-151.39a16,16,0,1,1,27-17.22C459.81,134.89,480,181.74,480,256c0,64.75-14.66,113.63-50.6,168.74A16,16,0,0,1,416,432Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
9
public/images/volume.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>ionicons-v5-g</title>
|
||||||
|
<path d="M232,416a23.88,23.88,0,0,1-14.2-4.68,8.27,8.27,0,0,1-.66-.51L125.76,336H56a24,24,0,0,1-24-24V200a24,24,0,0,1,24-24h69.75l91.37-74.81a8.27,8.27,0,0,1,.66-.51A24,24,0,0,1,256,120V392a24,24,0,0,1-24,24ZM125.82,336Zm-.27-159.86Z"/>
|
||||||
|
<path d="M320,336a16,16,0,0,1-14.29-23.19c9.49-18.87,14.3-38,14.3-56.81,0-19.38-4.66-37.94-14.25-56.73a16,16,0,0,1,28.5-14.54C346.19,208.12,352,231.44,352,256c0,23.86-6,47.81-17.7,71.19A16,16,0,0,1,320,336Z"/>
|
||||||
|
<path d="M368,384a16,16,0,0,1-13.86-24C373.05,327.09,384,299.51,384,256c0-44.17-10.93-71.56-29.82-103.94a16,16,0,0,1,27.64-16.12C402.92,172.11,416,204.81,416,256c0,50.43-13.06,83.29-34.13,120A16,16,0,0,1,368,384Z"/>
|
||||||
|
<path d="M416,432a16,16,0,0,1-13.39-24.74C429.85,365.47,448,323.76,448,256c0-66.5-18.18-108.62-45.49-151.39a16,16,0,1,1,27-17.22C459.81,134.89,480,181.74,480,256c0,64.75-14.66,113.63-50.6,168.74A16,16,0,0,1,416,432Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
20
public/images/wifi-connected.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 365.892 365.892" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<circle cx="182.945" cy="286.681" r="41.494"/>
|
||||||
|
<path d="M182.946,176.029c-35.658,0-69.337,17.345-90.09,46.398c-5.921,8.288-4.001,19.806,4.286,25.726
|
||||||
|
c3.249,2.321,6.994,3.438,10.704,3.438c5.754,0,11.423-2.686,15.021-7.724c13.846-19.383,36.305-30.954,60.078-30.954
|
||||||
|
c23.775,0,46.233,11.571,60.077,30.953c5.919,8.286,17.437,10.209,25.726,4.288c8.288-5.92,10.208-17.438,4.288-25.726
|
||||||
|
C252.285,193.373,218.606,176.029,182.946,176.029z"/>
|
||||||
|
<path d="M182.946,106.873c-50.938,0-99.694,21.749-133.77,59.67c-6.807,7.576-6.185,19.236,1.392,26.044
|
||||||
|
c3.523,3.166,7.929,4.725,12.32,4.725c5.051-0.001,10.082-2.063,13.723-6.116c27.091-30.148,65.849-47.439,106.336-47.439
|
||||||
|
s79.246,17.291,106.338,47.438c6.808,7.576,18.468,8.198,26.043,1.391c7.576-6.808,8.198-18.468,1.391-26.043
|
||||||
|
C282.641,128.621,233.883,106.873,182.946,106.873z"/>
|
||||||
|
<path d="M360.611,112.293c-47.209-48.092-110.305-74.577-177.665-74.577c-67.357,0-130.453,26.485-177.664,74.579
|
||||||
|
c-7.135,7.269-7.027,18.944,0.241,26.079c3.59,3.524,8.255,5.282,12.918,5.281c4.776,0,9.551-1.845,13.161-5.522
|
||||||
|
c40.22-40.971,93.968-63.534,151.344-63.534c57.379,0,111.127,22.563,151.343,63.532c7.136,7.269,18.812,7.376,26.08,0.242
|
||||||
|
C367.637,131.238,367.745,119.562,360.611,112.293z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
public/images/wifi-high.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}</style></defs><title>wifi-high</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>
|
||||||
|
After Width: | Height: | Size: 927 B |
1
public/images/wifi-low.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-low</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>
|
||||||
|
After Width: | Height: | Size: 944 B |
1
public/images/wifi-medium.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-medium</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>
|
||||||
|
After Width: | Height: | Size: 947 B |
1
public/images/wifi-off.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 75.6"><defs><style>.cls-1{fill:#666;}.cls-2{fill:#fff;}</style></defs><title>wifi-off</title><circle class="cls-1" cx="37.57" cy="68.74" r="6.87"/><path class="cls-2" d="M12.51,25.76" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M44.24,78.62" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M87.6,25.76" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M55.87,78.62" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -6.16)"/><rect class="cls-2" x="4.79" y="38.18" width="90.41" height="4.58" transform="translate(32.39 -29.8) rotate(46.4)"/><rect class="cls-2" x="4.79" y="38.18" width="90.41" height="4.58" transform="translate(101.36 26.02) rotate(133.6)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/images/wifi-vary-low.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-vary-low</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>
|
||||||
|
After Width: | Height: | Size: 949 B |
20
public/images/wifi.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 365.892 365.892" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<circle cx="182.945" cy="286.681" r="41.494"/>
|
||||||
|
<path d="M182.946,176.029c-35.658,0-69.337,17.345-90.09,46.398c-5.921,8.288-4.001,19.806,4.286,25.726
|
||||||
|
c3.249,2.321,6.994,3.438,10.704,3.438c5.754,0,11.423-2.686,15.021-7.724c13.846-19.383,36.305-30.954,60.078-30.954
|
||||||
|
c23.775,0,46.233,11.571,60.077,30.953c5.919,8.286,17.437,10.209,25.726,4.288c8.288-5.92,10.208-17.438,4.288-25.726
|
||||||
|
C252.285,193.373,218.606,176.029,182.946,176.029z"/>
|
||||||
|
<path d="M182.946,106.873c-50.938,0-99.694,21.749-133.77,59.67c-6.807,7.576-6.185,19.236,1.392,26.044
|
||||||
|
c3.523,3.166,7.929,4.725,12.32,4.725c5.051-0.001,10.082-2.063,13.723-6.116c27.091-30.148,65.849-47.439,106.336-47.439
|
||||||
|
s79.246,17.291,106.338,47.438c6.808,7.576,18.468,8.198,26.043,1.391c7.576-6.808,8.198-18.468,1.391-26.043
|
||||||
|
C282.641,128.621,233.883,106.873,182.946,106.873z"/>
|
||||||
|
<path d="M360.611,112.293c-47.209-48.092-110.305-74.577-177.665-74.577c-67.357,0-130.453,26.485-177.664,74.579
|
||||||
|
c-7.135,7.269-7.027,18.944,0.241,26.079c3.59,3.524,8.255,5.282,12.918,5.281c4.776,0,9.551-1.845,13.161-5.522
|
||||||
|
c40.22-40.971,93.968-63.534,151.344-63.534c57.379,0,111.127,22.563,151.343,63.532c7.136,7.269,18.812,7.376,26.08,0.242
|
||||||
|
C367.637,131.238,367.745,119.562,360.611,112.293z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/images/yandexMusic.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
43
public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/logo192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
38
scripts/start-electron.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Waits for Vite dev server, then launches Electron with ELECTRON_RUN_AS_NODE
|
||||||
|
// removed from environment (any value of that variable causes Electron to run
|
||||||
|
// as plain Node.js instead of a GUI app).
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const http = require('http');
|
||||||
|
const electronPath = require('electron');
|
||||||
|
|
||||||
|
const VITE_URL = 'http://localhost:5173';
|
||||||
|
const POLL_INTERVAL = 500;
|
||||||
|
const MAX_WAIT_MS = 60000;
|
||||||
|
|
||||||
|
function waitForVite(url, timeout) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
const check = () => {
|
||||||
|
http.get(url, (res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve();
|
||||||
|
}).on('error', () => {
|
||||||
|
if (Date.now() >= deadline) return reject(new Error('Timed out waiting for ' + url));
|
||||||
|
setTimeout(check, POLL_INTERVAL);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Waiting for Vite at', VITE_URL, '...');
|
||||||
|
waitForVite(VITE_URL, MAX_WAIT_MS).then(() => {
|
||||||
|
console.log('Vite ready — starting Electron');
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
const child = spawn(electronPath, ['.'], { stdio: 'inherit', env });
|
||||||
|
child.on('close', code => process.exit(code ?? 0));
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
12
sidebar.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sidebar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/entries/sidebar.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import HomePage from './pages/HomePage'
|
||||||
|
|
||||||
|
const App: React.FC = () => <HomePage />
|
||||||
|
|
||||||
|
export default App
|
||||||
27
src/components/AppCard.tsx
Normal 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
|
||||||
37
src/components/AppList.tsx
Normal 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
|
||||||
73
src/components/BookmarksBar.tsx
Normal 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
@@ -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
|
||||||
629
src/components/MovieSearch.tsx
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { MovieSite } from './Settings'
|
||||||
|
|
||||||
|
const Select: React.FC<{
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
placeholder?: string
|
||||||
|
}> = ({ value, onChange, options, placeholder }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const selected = options.find(o => o.value === value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={`ms-select${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)}>
|
||||||
|
<div className="ms-select-trigger">
|
||||||
|
<span className={selected && selected.value ? 'ms-select-active' : 'ms-select-placeholder'}>
|
||||||
|
{selected ? selected.label : (placeholder ?? '')}
|
||||||
|
</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="ms-select-dropdown" onClick={e => e.stopPropagation()}>
|
||||||
|
{options.map(o => (
|
||||||
|
<div
|
||||||
|
key={o.value}
|
||||||
|
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
|
||||||
|
onClick={() => { onChange(o.value); setOpen(false) }}
|
||||||
|
>{o.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
|
||||||
|
const MultiSelect: React.FC<{
|
||||||
|
values: string[]
|
||||||
|
onChange: (v: string[]) => void
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
placeholder: string
|
||||||
|
maxHeight?: number
|
||||||
|
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const toggle = (v: string) => {
|
||||||
|
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = values.length === 0
|
||||||
|
? placeholder
|
||||||
|
: values.length === 1
|
||||||
|
? (options.find(o => o.value === values[0])?.label ?? values[0])
|
||||||
|
: `${placeholder}: ${values.length}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
|
||||||
|
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
|
||||||
|
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
|
||||||
|
{values.length > 0 && (
|
||||||
|
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
|
||||||
|
{options.map(o => {
|
||||||
|
const on = values.includes(o.value)
|
||||||
|
return (
|
||||||
|
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
|
||||||
|
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}</span>
|
||||||
|
{o.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TmdbMovie {
|
||||||
|
id: number
|
||||||
|
mediaType: 'movie' | 'tv'
|
||||||
|
title: string
|
||||||
|
originalTitle: string
|
||||||
|
year: string
|
||||||
|
poster: string
|
||||||
|
overview: string
|
||||||
|
rating: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteResult {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
poster?: string
|
||||||
|
year?: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieSearchProps {
|
||||||
|
onOpenUrl: (name: string, url: string) => void
|
||||||
|
onBookmark?: (title: string, url: string, poster: string, source: string) => void
|
||||||
|
initialQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVIE_GENRES = [
|
||||||
|
{ id: 28, name: 'Боевик' }, { id: 12, name: 'Приключения' }, { id: 16, name: 'Мультфильм' },
|
||||||
|
{ id: 35, name: 'Комедия' }, { id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' },
|
||||||
|
{ id: 18, name: 'Драма' }, { id: 10751, name: 'Семейный' }, { id: 14, name: 'Фэнтези' },
|
||||||
|
{ id: 36, name: 'История' }, { id: 27, name: 'Ужасы' }, { id: 9648, name: 'Детектив' },
|
||||||
|
{ id: 10749, name: 'Мелодрама' }, { id: 878, name: 'Фантастика' }, { id: 53, name: 'Триллер' },
|
||||||
|
{ id: 10752, name: 'Военный' }, { id: 37, name: 'Вестерн' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TV_GENRES = [
|
||||||
|
{ id: 10759, name: 'Боевик' }, { id: 16, name: 'Мультфильм' }, { id: 35, name: 'Комедия' },
|
||||||
|
{ id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' }, { id: 18, name: 'Драма' },
|
||||||
|
{ id: 10751, name: 'Семейный' }, { id: 10762, name: 'Детское' }, { id: 9648, name: 'Детектив' },
|
||||||
|
{ id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, name: 'Вестерн' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ value: 'popularity.desc', label: 'Популярные' },
|
||||||
|
{ value: 'vote_average.desc', label: 'По рейтингу' },
|
||||||
|
{ value: 'release_date.desc', label: 'Новые' },
|
||||||
|
{ value: 'revenue.desc', label: 'По сборам' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RATINGS = [
|
||||||
|
{ value: '', label: 'Любой' },
|
||||||
|
{ value: '5', label: '5+' },
|
||||||
|
{ value: '6', label: '6+' },
|
||||||
|
{ value: '7', label: '7+' },
|
||||||
|
{ value: '8', label: '8+' },
|
||||||
|
{ value: '9', label: '9+' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COUNTRIES = [
|
||||||
|
{ value: '', label: 'Страна' },
|
||||||
|
{ value: 'US', label: 'США' },
|
||||||
|
{ value: 'RU', label: 'Россия' },
|
||||||
|
{ value: 'GB', label: 'Великобритания' },
|
||||||
|
{ value: 'FR', label: 'Франция' },
|
||||||
|
{ value: 'DE', label: 'Германия' },
|
||||||
|
{ value: 'IT', label: 'Италия' },
|
||||||
|
{ value: 'ES', label: 'Испания' },
|
||||||
|
{ value: 'JP', label: 'Япония' },
|
||||||
|
{ value: 'KR', label: 'Южная Корея' },
|
||||||
|
{ value: 'CN', label: 'Китай' },
|
||||||
|
{ value: 'IN', label: 'Индия' },
|
||||||
|
{ value: 'SE', label: 'Швеция' },
|
||||||
|
{ value: 'DK', label: 'Дания' },
|
||||||
|
{ value: 'TR', label: 'Турция' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
const YEARS = Array.from({ length: CURRENT_YEAR - 1899 }, (_, i) => CURRENT_YEAR + 1 - i)
|
||||||
|
|
||||||
|
const StarIcon = () => (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="#f5c518" stroke="none">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => (
|
||||||
|
<div
|
||||||
|
className="movie-result-card"
|
||||||
|
style={{ animationDelay: `${Math.max(0, idx - baseIdx) * 30}ms` }}
|
||||||
|
onClick={() => onSelect(movie)}
|
||||||
|
>
|
||||||
|
<div className="movie-result-poster">
|
||||||
|
{movie.poster
|
||||||
|
? <img src={movie.poster} alt={movie.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div className="movie-result-poster-placeholder" style={movie.poster ? { display: 'none' } : undefined}>
|
||||||
|
{movie.title.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="movie-result-info">
|
||||||
|
<span className="movie-result-title">{movie.title}</span>
|
||||||
|
<div className="ms-card-meta">
|
||||||
|
{movie.year && <span className="movie-result-year">{movie.year}</span>}
|
||||||
|
{movie.rating && <span className="ms-card-rating"><StarIcon /> {movie.rating}</span>}
|
||||||
|
</div>
|
||||||
|
{movie.mediaType === 'tv' && <span className="ms-card-type">Сериал</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initialQuery = '' }) => {
|
||||||
|
const [query, setQuery] = useState(initialQuery)
|
||||||
|
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
|
||||||
|
const [sortBy, setSortBy] = useState('popularity.desc')
|
||||||
|
const [genreIds, setGenreIds] = useState<number[]>([])
|
||||||
|
const [years, setYears] = useState<string[]>([])
|
||||||
|
const [minRating, setMinRating] = useState('')
|
||||||
|
const [countries, setCountries] = useState<string[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
|
||||||
|
const [activeQuery, setActiveQuery] = useState(initialQuery) // committed query (on Enter)
|
||||||
|
const [tmdbResults, setTmdbResults] = useState<TmdbMovie[]>([])
|
||||||
|
const [selected, setSelected] = useState<TmdbMovie | null>(null)
|
||||||
|
const [siteResults, setSiteResults] = useState<SiteResult[]>([])
|
||||||
|
const [tmdbLoading, setTmdbLoading] = useState(false)
|
||||||
|
const [sitesLoading, setSitesLoading] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [cardBase, setCardBase] = useState(0)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [sites, setSites] = useState<MovieSite[]>([])
|
||||||
|
const [configLoaded, setConfigLoaded] = useState(false)
|
||||||
|
|
||||||
|
const isSearchMode = activeQuery.trim().length > 0
|
||||||
|
const genres = mediaType === 'tv' ? TV_GENRES : MOVIE_GENRES
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron?.readConfig().then((cfg: any) => {
|
||||||
|
let enabled: MovieSite[] = (cfg?.movieSites ?? []).filter((s: MovieSite) => s.enabled !== false)
|
||||||
|
const NON_MOVIE = /youtube|rutube|vk\.com|ok\.ru|google|yandex|mail\.ru|twitch|tiktok|instagram|facebook|twitter|telegram/i
|
||||||
|
if (!enabled.length && cfg?.apps?.length) {
|
||||||
|
enabled = cfg.apps
|
||||||
|
.filter((app: any) => { try { return !NON_MOVIE.test(new URL(app.url).hostname) } catch { return false } })
|
||||||
|
.map((app: any) => {
|
||||||
|
let domain = app.url
|
||||||
|
try { domain = new URL(app.url).hostname } catch (_) {}
|
||||||
|
const type: MovieSite['type'] = /rezka/.test(domain) ? 'hdrezka' : /filmix/.test(domain) ? 'filmix' : 'dle'
|
||||||
|
return { domain, type, enabled: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const key: string = cfg?.tmdbApiKey ?? ''
|
||||||
|
setSites(enabled)
|
||||||
|
setApiKey(key)
|
||||||
|
setConfigLoaded(true)
|
||||||
|
if (initialQuery) {
|
||||||
|
if (key) doTmdbSearch(initialQuery, key)
|
||||||
|
else doSiteSearch(initialQuery, enabled, undefined, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-load discover when config ready or filters change (discover mode only)
|
||||||
|
const discoverRef = useRef(0)
|
||||||
|
const doDiscover = useCallback(async (key: string, pg: number, append: boolean) => {
|
||||||
|
const token = ++discoverRef.current
|
||||||
|
if (append) setLoadingMore(true)
|
||||||
|
else { setTmdbLoading(true); setMessage('') }
|
||||||
|
try {
|
||||||
|
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
|
||||||
|
if (token !== discoverRef.current) return
|
||||||
|
if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
|
||||||
|
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
|
||||||
|
setTotalPages(res.totalPages)
|
||||||
|
if (!res.results.length && !append) setMessage('Ничего не найдено')
|
||||||
|
} catch {
|
||||||
|
if (token === discoverRef.current) setMessage('Ошибка загрузки')
|
||||||
|
} finally {
|
||||||
|
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
|
||||||
|
}
|
||||||
|
}, [mediaType, sortBy, genreIds, years, minRating, countries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configLoaded || !apiKey || isSearchMode) return
|
||||||
|
setPage(1)
|
||||||
|
setTmdbResults([])
|
||||||
|
doDiscover(apiKey, 1, false)
|
||||||
|
}, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
|
||||||
|
|
||||||
|
const searchRef = useRef(0)
|
||||||
|
const doTmdbSearch = async (q: string, key: string) => {
|
||||||
|
const token = ++searchRef.current
|
||||||
|
setTmdbLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
setTmdbResults([])
|
||||||
|
setSelected(null)
|
||||||
|
setSiteResults([])
|
||||||
|
try {
|
||||||
|
const res = await window.electron!.searchTmdb(q, key)
|
||||||
|
if (token !== searchRef.current) return
|
||||||
|
if (res.error) { setMessage(`Ошибка TMDB: ${res.error}`); return }
|
||||||
|
setTmdbResults(res.results)
|
||||||
|
setTotalPages(1)
|
||||||
|
if (!res.results.length) setMessage('Ничего не найдено')
|
||||||
|
} catch {
|
||||||
|
if (token === searchRef.current) setMessage('Ошибка TMDB')
|
||||||
|
} finally {
|
||||||
|
if (token === searchRef.current) setTmdbLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doSiteSearch = async (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string) => {
|
||||||
|
if (!sitesToSearch.length) { setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.'); return }
|
||||||
|
setSitesLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
setSiteResults([])
|
||||||
|
try {
|
||||||
|
const data = await window.electron!.searchMovies(q, sitesToSearch)
|
||||||
|
let filtered = data
|
||||||
|
if (yearHint) {
|
||||||
|
const y = parseInt(yearHint)
|
||||||
|
const isTv = mt === 'tv'
|
||||||
|
const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5
|
||||||
|
const normalizeTitle = (t: string) => t.toLowerCase().replace(/[^а-яёa-z0-9]/gi, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
const groups = new Map<string, SiteResult[]>()
|
||||||
|
for (const r of data) {
|
||||||
|
const key = normalizeTitle(r.title)
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key)!.push(r)
|
||||||
|
}
|
||||||
|
const deduped: SiteResult[] = []
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
const minDist = Math.min(...group.map(yearDist))
|
||||||
|
deduped.push(...group.filter(r => yearDist(r) === minDist))
|
||||||
|
}
|
||||||
|
filtered = isTv
|
||||||
|
? deduped.sort((a, b) => yearDist(a) - yearDist(b))
|
||||||
|
: deduped.filter(r => !r.year || yearDist(r) <= 1).sort((a, b) => yearDist(a) - yearDist(b))
|
||||||
|
}
|
||||||
|
setSiteResults(filtered)
|
||||||
|
if (!filtered.length) setMessage('Не найдено ни на одном сайте')
|
||||||
|
} catch {
|
||||||
|
setMessage('Ошибка поиска по сайтам')
|
||||||
|
} finally {
|
||||||
|
setSitesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) return
|
||||||
|
setActiveQuery(q)
|
||||||
|
if (apiKey) doTmdbSearch(q, apiKey)
|
||||||
|
else doSiteSearch(q, sites, undefined, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectMovie = (movie: TmdbMovie) => {
|
||||||
|
setSelected(movie)
|
||||||
|
setSiteResults([])
|
||||||
|
setMessage('')
|
||||||
|
const searchTitle = movie.title || movie.originalTitle
|
||||||
|
doSiteSearch(searchTitle, sites, movie.year, movie.mediaType)
|
||||||
|
if (movie.originalTitle && movie.originalTitle !== movie.title) {
|
||||||
|
window.electron!.searchMovies(movie.originalTitle, sites).then(extra => {
|
||||||
|
setSiteResults(prev => {
|
||||||
|
const existing = new Set(prev.map(r => r.url))
|
||||||
|
return [...prev, ...extra.filter(r => !existing.has(r.url))]
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => { setSelected(null); setSiteResults([]); setMessage('') }
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
const nextPage = page + 1
|
||||||
|
setPage(nextPage)
|
||||||
|
setCardBase(tmdbResults.length)
|
||||||
|
doDiscover(apiKey, nextPage, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = (fn: () => void) => {
|
||||||
|
setSelected(null)
|
||||||
|
setSiteResults([])
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearQuery = () => {
|
||||||
|
setQuery('')
|
||||||
|
setActiveQuery('')
|
||||||
|
setTmdbResults([])
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = tmdbLoading || sitesLoading
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="movie-search">
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="movie-search-bar">
|
||||||
|
<input
|
||||||
|
className="header-search-input movie-search-input"
|
||||||
|
placeholder="Название фильма или сериала..."
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && !loading && handleSearch()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button className="ms-clear-btn" onClick={clearQuery} title="Очистить">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="header-search-btn movie-search-btn" onClick={handleSearch} disabled={loading}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters (only in discover mode with TMDB key) */}
|
||||||
|
{apiKey && !selected && (
|
||||||
|
<div className="ms-filters">
|
||||||
|
<div className="ms-filter-row">
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="ms-type-toggle">
|
||||||
|
<button
|
||||||
|
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
|
||||||
|
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
|
||||||
|
>Фильмы</button>
|
||||||
|
<button
|
||||||
|
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
|
||||||
|
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
|
||||||
|
>Сериалы</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
{!isSearchMode && (
|
||||||
|
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Min rating (single, threshold) */}
|
||||||
|
{!isSearchMode && (
|
||||||
|
<Select
|
||||||
|
value={minRating}
|
||||||
|
onChange={v => handleFilterChange(() => setMinRating(v))}
|
||||||
|
options={RATINGS.map(r => ({ value: r.value, label: r.label === 'Любой' ? 'Рейтинг' : r.label }))}
|
||||||
|
placeholder="Рейтинг"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Years (multi, OR) */}
|
||||||
|
<MultiSelect
|
||||||
|
values={years}
|
||||||
|
onChange={v => handleFilterChange(() => setYears(v))}
|
||||||
|
options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
|
||||||
|
placeholder="Год"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Countries (multi, OR) */}
|
||||||
|
{!isSearchMode && (
|
||||||
|
<MultiSelect
|
||||||
|
values={countries}
|
||||||
|
onChange={v => handleFilterChange(() => setCountries(v))}
|
||||||
|
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
|
||||||
|
placeholder="Страна"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
|
||||||
|
{!isSearchMode && (
|
||||||
|
<div className="ms-genres">
|
||||||
|
<button
|
||||||
|
className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
|
||||||
|
onClick={() => handleFilterChange(() => setGenreIds([]))}
|
||||||
|
title="Сбросить жанры"
|
||||||
|
>Все</button>
|
||||||
|
{genres.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
|
||||||
|
onClick={() => handleFilterChange(() =>
|
||||||
|
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
|
||||||
|
)}
|
||||||
|
>{g.name}</button>
|
||||||
|
))}
|
||||||
|
{genreIds.length > 1 && (
|
||||||
|
<span className="ms-genres-hint">все выбранные одновременно</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && <p className="movie-search-message">{message}</p>}
|
||||||
|
|
||||||
|
{/* Detail view */}
|
||||||
|
{selected ? (
|
||||||
|
<div className="ms-detail">
|
||||||
|
{selected.poster && (
|
||||||
|
<div className="ms-detail-bg">
|
||||||
|
<img className="ms-detail-bg-img" src={selected.poster} alt="" />
|
||||||
|
<div className="ms-detail-bg-gradient" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ms-detail-content">
|
||||||
|
<button className="ms-back-btn" onClick={handleBack}>← Назад</button>
|
||||||
|
<div className="ms-detail-card">
|
||||||
|
{selected.poster
|
||||||
|
? <img className="ms-detail-poster" src={selected.poster} alt={selected.title} />
|
||||||
|
: <div className="ms-poster-placeholder">{selected.title.charAt(0)}</div>
|
||||||
|
}
|
||||||
|
<div className="ms-detail-info">
|
||||||
|
<h2 className="ms-detail-title">{selected.title}</h2>
|
||||||
|
{selected.originalTitle !== selected.title && (
|
||||||
|
<p className="ms-detail-orig">{selected.originalTitle}</p>
|
||||||
|
)}
|
||||||
|
<div className="ms-detail-meta">
|
||||||
|
{selected.year && <span>{selected.year}</span>}
|
||||||
|
{selected.mediaType === 'tv' && <span>Сериал</span>}
|
||||||
|
{selected.rating && <span><StarIcon /> {selected.rating}</span>}
|
||||||
|
</div>
|
||||||
|
{selected.overview && <p className="ms-detail-overview">{selected.overview}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-site-results">
|
||||||
|
{sitesLoading && <p className="movie-search-message">Ищем на {sites.length} сайтах...</p>}
|
||||||
|
{!sitesLoading && !siteResults.length && !message && <p className="movie-search-message">Поиск...</p>}
|
||||||
|
{!sitesLoading && siteResults.length > 0 && (
|
||||||
|
<div className="ms-sites-label">
|
||||||
|
Найдено на сайтах
|
||||||
|
<button className="ms-retry-btn" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)} title="Повторить поиск">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!sitesLoading && !siteResults.length && (
|
||||||
|
<button className="ms-retry-btn ms-retry-standalone" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)}>
|
||||||
|
Повторить поиск
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{siteResults.map((r, i) => (
|
||||||
|
<div key={i} className="ms-site-row" onClick={() => onOpenUrl(r.title, r.url)}>
|
||||||
|
<span className="ms-site-source">{r.source}</span>
|
||||||
|
<span className="ms-site-title">{r.title}</span>
|
||||||
|
<div className="ms-site-actions">
|
||||||
|
{onBookmark && (
|
||||||
|
<button className="ms-bookmark-btn" title="В закладки" onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onBookmark(selected?.title || r.title, r.url, selected?.poster || '', r.source)
|
||||||
|
}}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ms-site-open">Открыть →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{tmdbLoading && <p className="movie-search-message">{isSearchMode ? 'Поиск...' : 'Загрузка...'}</p>}
|
||||||
|
|
||||||
|
{tmdbResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="movie-results">
|
||||||
|
{tmdbResults.map((movie, i) => <MovieCard key={movie.id} movie={movie} idx={i} baseIdx={cardBase} onSelect={handleSelectMovie} />)}
|
||||||
|
</div>
|
||||||
|
{!isSearchMode && page < totalPages && (
|
||||||
|
<button className="ms-load-more" onClick={handleLoadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore ? 'Загрузка...' : 'Загрузить ещё'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Direct site results (no TMDB key) */}
|
||||||
|
{siteResults.length > 0 && (
|
||||||
|
<div className="movie-results">
|
||||||
|
{siteResults.map((r, i) => (
|
||||||
|
<div key={i} className="movie-result-card" onClick={() => onOpenUrl(r.title, r.url)}>
|
||||||
|
<div className="movie-result-poster">
|
||||||
|
{r.poster
|
||||||
|
? <img src={r.poster} alt={r.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div className="movie-result-poster-placeholder" style={r.poster ? { display: 'none' } : undefined}>{r.title.charAt(0).toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="movie-result-info">
|
||||||
|
<span className="movie-result-title">{r.title}</span>
|
||||||
|
{r.year && <span className="movie-result-year">{r.year}</span>}
|
||||||
|
<span className="movie-result-source">{r.source}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MovieSearch
|
||||||
354
src/components/Settings.tsx
Normal 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
|
||||||
72
src/components/Sidebar.tsx
Normal 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
|
||||||
26
src/components/SidebarElement.tsx
Normal 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
|
||||||
40
src/entries/dialog-confirm.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import '../styles/dialogs.css'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron?: { handleAction: (action: string) => void }
|
||||||
|
__dialogData?: { text?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog = () => {
|
||||||
|
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)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) document.body.classList.add('visible')
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
{text && <div className="msg">{text}</div>}
|
||||||
|
<div className="btns">
|
||||||
|
<button className="btn-yes" onClick={() => window.electron?.handleAction('confirmYes')}>
|
||||||
|
Да
|
||||||
|
</button>
|
||||||
|
<button className="btn-no" onClick={() => window.electron?.handleAction('confirmNo')}>
|
||||||
|
Нет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(<ConfirmDialog />)
|
||||||
39
src/entries/dialog-error.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import '../styles/dialogs.css'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron?: { handleAction: (action: string) => void }
|
||||||
|
__dialogData?: { title?: string; text?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorDialog = () => {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const title = params.get('title') || window.__dialogData?.title || 'Ошибка'
|
||||||
|
const text = params.get('text') || window.__dialogData?.text || ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) document.body.classList.add('visible')
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
{title && <div className="title">{title}</div>}
|
||||||
|
{text && <div className="msg">{text}</div>}
|
||||||
|
<div className="btns">
|
||||||
|
<button className="btn-ok" onClick={() => window.electron?.handleAction('error')}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(<ErrorDialog />)
|
||||||
40
src/entries/loader.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
const css = `
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #111;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
body.visible { opacity: 1; }
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.1);
|
||||||
|
border-top-color: #E50914;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
`
|
||||||
|
|
||||||
|
const Loader = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = css
|
||||||
|
document.head.appendChild(style)
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
document.body.classList.add('visible')
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div className="spinner" />
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(<Loader />)
|
||||||
99
src/entries/sidebar.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import '../styles/main.css'
|
||||||
|
|
||||||
|
interface OpenedApp {
|
||||||
|
name: string
|
||||||
|
imageUrl: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron?: {
|
||||||
|
on: (channel: string, fn: (...args: any[]) => void) => () => void
|
||||||
|
hideView: () => void
|
||||||
|
showView: (name: string) => void
|
||||||
|
adjustView: (expanded: boolean) => void
|
||||||
|
getSidebarState: () => Promise<{ openedApps: OpenedApp[]; activeApp: string }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarApp = () => {
|
||||||
|
const [openedApps, setOpenedApps] = useState<OpenedApp[]>([])
|
||||||
|
const [activeApp, setActiveApp] = useState('home')
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron?.getSidebarState().then(data => {
|
||||||
|
if (!data) return
|
||||||
|
setOpenedApps(data.openedApps || [])
|
||||||
|
setActiveApp(data.activeApp || 'home')
|
||||||
|
})
|
||||||
|
const off = window.electron?.on('sidebar-update', (data: { openedApps: OpenedApp[]; activeApp: string }) => {
|
||||||
|
setOpenedApps(data.openedApps || [])
|
||||||
|
setActiveApp(data.activeApp || 'home')
|
||||||
|
})
|
||||||
|
return () => off?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => (
|
||||||
|
<div
|
||||||
|
key={app.name}
|
||||||
|
className={`sidebar-item ${activeApp === app.name ? 'active' : ''}`}
|
||||||
|
onClick={() => window.electron?.showView(app.name)}
|
||||||
|
>
|
||||||
|
{app.imageUrl ? (
|
||||||
|
<img src={app.imageUrl} alt={app.name} className="sidebar-app-icon" />
|
||||||
|
) : (
|
||||||
|
<div className="sidebar-app-icon sidebar-app-icon-placeholder">
|
||||||
|
{app.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{app.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(<SidebarApp />)
|
||||||
41
src/main.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron?: {
|
||||||
|
createView: (name: string, url: string, imageUrl: string, zoom: number, useProxy: boolean) => void
|
||||||
|
confirm: (text: string, funcName: string) => void
|
||||||
|
removeView: (name?: string) => void
|
||||||
|
hideView: () => void
|
||||||
|
showView: (name: string) => void
|
||||||
|
adjustView: (expanded: boolean) => void
|
||||||
|
on: (channel: string, func: (...args: any[]) => void) => () => void
|
||||||
|
getCurrentPage: () => Promise<{ name: string; url: string; imageUrl: string } | null>
|
||||||
|
handleAction: (action: string) => void
|
||||||
|
setProxy: (host: string, port: string) => void
|
||||||
|
expandWithHeader: () => void
|
||||||
|
collapseWithHeader: () => void
|
||||||
|
backwardPage: () => void
|
||||||
|
forwardPage: () => void
|
||||||
|
refreshPage: () => void
|
||||||
|
readConfig: () => Promise<any>
|
||||||
|
writeConfig: (data: any) => void
|
||||||
|
searchMovies: (query: string, sites: any[]) => Promise<any[]>
|
||||||
|
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
|
||||||
|
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
|
||||||
|
getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
|
||||||
|
installUpdate: () => Promise<boolean>
|
||||||
|
checkUpdateNow: () => Promise<boolean>
|
||||||
|
toggleKiosk: () => Promise<boolean>
|
||||||
|
isKiosk: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
124
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import Header from '../components/Header'
|
||||||
|
import Sidebar from '../components/Sidebar'
|
||||||
|
import AppList from '../components/AppList'
|
||||||
|
import MovieSearch from '../components/MovieSearch'
|
||||||
|
import '../styles/main.css'
|
||||||
|
import { AppEntry, Bookmark } from '../components/Settings'
|
||||||
|
|
||||||
|
interface OpenedApp {
|
||||||
|
name: string
|
||||||
|
imageUrl: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
const [openedApps, setOpenedApps] = useState<OpenedApp[]>([])
|
||||||
|
const [activeApp, setActiveApp] = useState<string>('home')
|
||||||
|
const [appCardList, setAppCardList] = useState<AppEntry[]>([])
|
||||||
|
const [movieQuery, setMovieQuery] = useState<string | null>(null)
|
||||||
|
const [movieSearchKey, setMovieSearchKey] = useState(0)
|
||||||
|
const [openedFromSearch, setOpenedFromSearch] = useState(false)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const configRef = useRef<any>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electron) return
|
||||||
|
window.electron.readConfig().then((cfg: any) => {
|
||||||
|
configRef.current = cfg ?? {}
|
||||||
|
if (cfg?.apps) setAppCardList(cfg.apps)
|
||||||
|
if (cfg?.bookmarks) setBookmarks(cfg.bookmarks)
|
||||||
|
if (cfg?.proxy?.host && cfg?.proxy?.port) {
|
||||||
|
window.electron!.setProxy(cfg.proxy.host, cfg.proxy.port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const offOpenedApps = window.electron.on('update-opened-apps', (apps: OpenedApp[], activeName: string) => {
|
||||||
|
setOpenedApps(apps)
|
||||||
|
setActiveApp(activeName ?? 'home')
|
||||||
|
})
|
||||||
|
const offAlert = window.electron.on('alert', (text: string) => alert(text))
|
||||||
|
return () => { offOpenedApps(); offAlert() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSidebarAppClick = (name: string) => {
|
||||||
|
setActiveApp(name)
|
||||||
|
window.electron?.showView(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMovieSearch = (query: string) => {
|
||||||
|
window.electron?.hideView()
|
||||||
|
setMovieQuery(query)
|
||||||
|
setMovieSearchKey(k => k + 1)
|
||||||
|
setOpenedFromSearch(false)
|
||||||
|
setActiveApp('movie-search')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMovieSearchOpen = (name: string, url: string) => {
|
||||||
|
window.electron?.createView(name, url, '', 1.0, resolveUseProxy(url))
|
||||||
|
setOpenedFromSearch(true)
|
||||||
|
setActiveApp(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackToSearch = () => {
|
||||||
|
window.electron?.hideView()
|
||||||
|
setActiveApp('movie-search')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBookmarkAdd = (title: string, url: string, poster: string, source: string, siteIcon?: string) => {
|
||||||
|
// If caller didn't pass a site icon (e.g. movie search), look it up from the apps config by host.
|
||||||
|
let icon = siteIcon || ''
|
||||||
|
if (!icon) {
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname
|
||||||
|
const match = appCardList.find(a => { try { return new URL(a.url).hostname === host } catch { return false } })
|
||||||
|
if (match?.imageUrl) icon = match.imageUrl
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const sourceStr = source || (() => { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return '' } })()
|
||||||
|
const updated = [...bookmarks, { title, url, poster, source: sourceStr, siteIcon: icon }]
|
||||||
|
setBookmarks(updated)
|
||||||
|
configRef.current = { ...configRef.current, bookmarks: updated }
|
||||||
|
window.electron?.writeConfig(configRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBookmarkRemove = (index: number) => {
|
||||||
|
const updated = bookmarks.filter((_, i) => i !== index)
|
||||||
|
setBookmarks(updated)
|
||||||
|
configRef.current = { ...configRef.current, bookmarks: updated }
|
||||||
|
window.electron?.writeConfig(configRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveUseProxy = (url: string) => {
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname
|
||||||
|
const match = appCardList.find(a => { try { return new URL(a.url).hostname === host } catch { return false } })
|
||||||
|
return match ? match.useProxy : true
|
||||||
|
} catch { return true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBookmarkOpen = (b: Bookmark) => {
|
||||||
|
window.electron?.createView(b.title, b.url, b.poster || '', 1.0, resolveUseProxy(b.url))
|
||||||
|
setActiveApp(b.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarApps = openedApps.map(app => ({
|
||||||
|
...app,
|
||||||
|
isActive: activeApp === app.name,
|
||||||
|
onClick: () => handleSidebarAppClick(app.name),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header activeApp={activeApp} setActiveApp={setActiveApp} onAppsChange={setAppCardList} onMovieSearch={handleMovieSearch} onBookmark={handleBookmarkAdd} onBookmarkRemove={handleBookmarkRemove} bookmarks={bookmarks} openedFromSearch={openedFromSearch} onBackToSearch={handleBackToSearch} />
|
||||||
|
<Sidebar openedApps={sidebarApps} activeApp={activeApp} setActiveApp={setActiveApp} />
|
||||||
|
<div style={{ display: activeApp === 'movie-search' ? undefined : 'none' }}>
|
||||||
|
<MovieSearch key={movieSearchKey} initialQuery={movieQuery ?? ''} onOpenUrl={handleMovieSearchOpen} onBookmark={handleBookmarkAdd} />
|
||||||
|
</div>
|
||||||
|
{activeApp !== 'movie-search' && (
|
||||||
|
<AppList apps={appCardList} bookmarks={bookmarks} onBookmarkOpen={handleBookmarkOpen} onBookmarkRemove={handleBookmarkRemove} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
52
src/styles/dialogs.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
transition: background 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.visible { background: rgba(0, 0, 0, 0.78); }
|
||||||
|
body.hiding { background: rgba(0, 0, 0, 0); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1c1c1c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 36px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(10px);
|
||||||
|
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.visible .card { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
body.hiding .card { opacity: 0; transform: scale(0.95) translateY(6px); }
|
||||||
|
|
||||||
|
.title { font-size: 17px; font-weight: 700; color: #fff; margin-bottom: 10px; }
|
||||||
|
.msg { font-size: 13px; color: #999; line-height: 1.5; margin-bottom: 26px; }
|
||||||
|
|
||||||
|
.btns { display: flex; gap: 10px; justify-content: center; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 26px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
button:hover { opacity: 0.85; }
|
||||||
|
button:active { transform: scale(0.97); }
|
||||||
|
|
||||||
|
.btn-yes { background: #E50914; color: #fff; }
|
||||||
|
.btn-no,
|
||||||
|
.btn-ok { background: rgba(255, 255, 255, 0.1); color: #ccc; }
|
||||||
1534
src/styles/main.css
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
29
vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
loader: resolve(__dirname, 'loader.html'),
|
||||||
|
'dialog-error': resolve(__dirname, 'dialog-error.html'),
|
||||||
|
'dialog-confirm': resolve(__dirname, 'dialog-confirm.html'),
|
||||||
|
sidebar: resolve(__dirname, 'sidebar.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/extensions/**', '**/node_modules/**', '**/release/**', '**/dist/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
entries: ['index.html', 'loader.html', 'dialog-error.html', 'dialog-confirm.html', 'src/**/*.tsx', 'src/**/*.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||