From 1c7bb75a05127f5a8a8beaf92d34655e1b5850aa Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Sun, 17 May 2026 00:46:02 +0300 Subject: [PATCH] =?UTF-8?q?ESH-Media=20v1.0.11=20=E2=80=94=20kiosk=20media?= =?UTF-8?q?=20browser=20for=20elderly=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 7 + .vscode/settings.json | 3 + README.md | 87 + dialog-confirm.html | 12 + dialog-error.html | 12 + index.html | 12 + loader.html | 12 + main.js | 1462 +++++++ package-lock.json | 6522 ++++++++++++++++++++++++++++ package.json | 75 + preload.js | 35 + public/favicon.ico | Bin 0 -> 18404 bytes public/images/RuTube.jpg | Bin 0 -> 5102 bytes public/images/VPN.png | Bin 0 -> 972 bytes public/images/Youtube_logo.png | Bin 0 -> 10682 bytes public/images/bluetooth-on.svg | 14 + public/images/bluetooth.svg | 14 + public/images/church.png | Bin 0 -> 427 bytes public/images/collapse.png | Bin 0 -> 607 bytes public/images/expand.png | Bin 0 -> 600 bytes public/images/home.png | Bin 0 -> 5472 bytes public/images/home.svg | 1 + public/images/ivi.png | Bin 0 -> 8886 bytes public/images/kinogo.png | Bin 0 -> 2726 bytes public/images/kinopoisk.png | Bin 0 -> 23138 bytes public/images/left-disabled.png | Bin 0 -> 342 bytes public/images/left.png | Bin 0 -> 341 bytes public/images/refresh-disabled.png | Bin 0 -> 1026 bytes public/images/refresh.png | Bin 0 -> 848 bytes public/images/right-disabled.png | Bin 0 -> 363 bytes public/images/right.png | Bin 0 -> 350 bytes public/images/tv.png | Bin 0 -> 156057 bytes public/images/volume-high.svg | 9 + public/images/volume.svg | 9 + public/images/wifi-connected.svg | 20 + public/images/wifi-high.svg | 1 + public/images/wifi-low.svg | 1 + public/images/wifi-medium.svg | 1 + public/images/wifi-off.svg | 1 + public/images/wifi-vary-low.svg | 1 + public/images/wifi.svg | 20 + public/images/yandexMusic.png | Bin 0 -> 31533 bytes public/index.html | 43 + public/logo.png | Bin 0 -> 22600 bytes public/logo192.png | Bin 0 -> 5347 bytes public/logo512.png | Bin 0 -> 9664 bytes public/manifest.json | 25 + public/robots.txt | 3 + scripts/start-electron.js | 38 + sidebar.html | 12 + src/App.tsx | 6 + src/components/AppCard.tsx | 27 + src/components/AppList.tsx | 37 + src/components/BookmarksBar.tsx | 73 + src/components/Header.tsx | 344 ++ src/components/MovieSearch.tsx | 629 +++ src/components/Settings.tsx | 354 ++ src/components/Sidebar.tsx | 72 + src/components/SidebarElement.tsx | 26 + src/entries/dialog-confirm.tsx | 40 + src/entries/dialog-error.tsx | 39 + src/entries/loader.tsx | 40 + src/entries/sidebar.tsx | 99 + src/main.tsx | 41 + src/pages/HomePage.tsx | 124 + src/styles/dialogs.css | 52 + src/styles/main.css | 1534 +++++++ tsconfig.json | 17 + vite.config.ts | 29 + 69 files changed, 12035 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 dialog-confirm.html create mode 100644 dialog-error.html create mode 100644 index.html create mode 100644 loader.html create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 public/favicon.ico create mode 100644 public/images/RuTube.jpg create mode 100644 public/images/VPN.png create mode 100644 public/images/Youtube_logo.png create mode 100644 public/images/bluetooth-on.svg create mode 100644 public/images/bluetooth.svg create mode 100644 public/images/church.png create mode 100644 public/images/collapse.png create mode 100644 public/images/expand.png create mode 100644 public/images/home.png create mode 100644 public/images/home.svg create mode 100644 public/images/ivi.png create mode 100644 public/images/kinogo.png create mode 100644 public/images/kinopoisk.png create mode 100644 public/images/left-disabled.png create mode 100644 public/images/left.png create mode 100644 public/images/refresh-disabled.png create mode 100644 public/images/refresh.png create mode 100644 public/images/right-disabled.png create mode 100644 public/images/right.png create mode 100644 public/images/tv.png create mode 100644 public/images/volume-high.svg create mode 100644 public/images/volume.svg create mode 100644 public/images/wifi-connected.svg create mode 100644 public/images/wifi-high.svg create mode 100644 public/images/wifi-low.svg create mode 100644 public/images/wifi-medium.svg create mode 100644 public/images/wifi-off.svg create mode 100644 public/images/wifi-vary-low.svg create mode 100644 public/images/wifi.svg create mode 100644 public/images/yandexMusic.png create mode 100644 public/index.html create mode 100644 public/logo.png create mode 100644 public/logo192.png create mode 100644 public/logo512.png create mode 100644 public/manifest.json create mode 100644 public/robots.txt create mode 100644 scripts/start-electron.js create mode 100644 sidebar.html create mode 100644 src/App.tsx create mode 100644 src/components/AppCard.tsx create mode 100644 src/components/AppList.tsx create mode 100644 src/components/BookmarksBar.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/MovieSearch.tsx create mode 100644 src/components/Settings.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/SidebarElement.tsx create mode 100644 src/entries/dialog-confirm.tsx create mode 100644 src/entries/dialog-error.tsx create mode 100644 src/entries/loader.tsx create mode 100644 src/entries/sidebar.tsx create mode 100644 src/main.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/styles/dialogs.css create mode 100644 src/styles/main.css create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4eab680 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +dist-electron/ +release/ +*.log +.DS_Store +.claude/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e308575 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "DockerRun.DisableDockerrc": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3616037 --- /dev/null +++ b/README.md @@ -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/ — стили +``` diff --git a/dialog-confirm.html b/dialog-confirm.html new file mode 100644 index 0000000..529505e --- /dev/null +++ b/dialog-confirm.html @@ -0,0 +1,12 @@ + + + + + + Confirm + + +
+ + + diff --git a/dialog-error.html b/dialog-error.html new file mode 100644 index 0000000..b826816 --- /dev/null +++ b/dialog-error.html @@ -0,0 +1,12 @@ + + + + + + Error + + +
+ + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..4151162 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + ESH-Media + + +
+ + + diff --git a/loader.html b/loader.html new file mode 100644 index 0000000..78db286 --- /dev/null +++ b/loader.html @@ -0,0 +1,12 @@ + + + + + + Loading + + +
+ + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..3da5e9b --- /dev/null +++ b/main.js @@ -0,0 +1,1462 @@ +const { app, BrowserWindow, WebContentsView, ipcMain, session } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const cheerio = require('cheerio'); +const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron'); +const { autoUpdater } = require('electron-updater'); + +// Disable Trusted Types CSP enforcement engine-wide. +// YouTube sends `Content-Security-Policy: require-trusted-types-for 'script'`, +// which blocks the cliqz adblocker's scriptlet injection (it uses plain +// `script.text = ...`) → 52+ console errors and broken anti-adblock neutralizers. +// Stripping the CSP header via webRequest doesn't work — the adblocker's own +// onHeadersReceived hook overwrites ours (Electron allows only one listener +// per session). Disabling the Blink feature is the cleanest fix; safe in a +// kiosk single-user context. +app.commandLine.appendSwitch('disable-blink-features', 'TrustedDOMTypes'); + +const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json'); +const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v3.bin'); +const DEFAULT_TRUSTED_DOMAINS = [ + // Google ecosystem (OAuth) + 'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com', + 'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com', + // Yandex + 'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net', + // GitHub + 'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com', + // VK / Mail.ru + 'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru', + // Microsoft (login.live.com etc., некоторые сайты через них) + 'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com', + // Apple + 'apple.com', 'icloud.com', + // Facebook (для соцлогина) + 'facebook.com', 'fb.com', +]; +const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, trustedDomains: DEFAULT_TRUSTED_DOMAINS }; + +let blockerPromise = null; +let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS; + +// chrome.* spoof: injected via executeJavaScript on every page's dom-ready. +// Goal is to look like real Chrome to JS-based "embedded browser" detectors +// (Google login, etc.). Cannot fix TLS-fingerprint detection — that's server-side. +const CHROME_SPOOF_JS = `(function(){ + try { + if (!window.chrome) window.chrome = {}; + var c = window.chrome; + if (!c.app) c.app = { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + getDetails: function(){ return null; }, + getIsInstalled: function(){ return false; }, + runningState: function(){ return 'cannot_run'; } + }; + if (!c.runtime) c.runtime = { + PlatformOs: { MAC:'mac', WIN:'win', ANDROID:'android', CROS:'cros', LINUX:'linux', OPENBSD:'openbsd' }, + PlatformArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' }, + PlatformNaclArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' }, + RequestUpdateCheckStatus: { NO_UPDATE:'no_update', THROTTLED:'throttled', UPDATE_AVAILABLE:'update_available' }, + OnInstalledReason: { CHROME_UPDATE:'chrome_update', INSTALL:'install', SHARED_MODULE_UPDATE:'shared_module_update', UPDATE:'update' }, + OnRestartRequiredReason: { APP_UPDATE:'app_update', OS_UPDATE:'os_update', PERIODIC:'periodic' }, + sendMessage: function(){}, + connect: function(){ + return { + postMessage: function(){}, disconnect: function(){}, + onDisconnect: { addListener: function(){}, removeListener: function(){} }, + onMessage: { addListener: function(){}, removeListener: function(){} } + }; + } + }; + if (!c.csi) c.csi = function(){ return { startE: Date.now()-1000, onloadT: Date.now()-500, pageT: 1000, tran: 15 }; }; + if (!c.loadTimes) c.loadTimes = function(){ + var t = performance.timing; + return { + commitLoadTime: t.responseStart/1000, connectionInfo: 'http/1.1', + finishDocumentLoadTime: t.domContentLoadedEventEnd/1000, + finishLoadTime: (t.loadEventEnd/1000) || 0, + firstPaintAfterLoadTime: 0, firstPaintTime: t.responseEnd/1000, + navigationType: 'Other', npnNegotiatedProtocol: 'h2', + requestTime: t.requestStart/1000, startLoadTime: t.fetchStart/1000, + wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true + }; + }; + // navigator.permissions.query: Notification permission must agree with Notification.permission + if (navigator.permissions && navigator.permissions.query) { + var origQuery = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = function(p){ + if (p && p.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null }); + return origQuery(p); + }; + } + } catch (_) {} +})();`; + +function loadTrustedDomainsFromDisk() { + try { + if (fs.existsSync(CONFIG_PATH)) { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + if (Array.isArray(cfg.trustedDomains) && cfg.trustedDomains.length) { + cachedTrustedDomains = cfg.trustedDomains; + } + } + } catch (_) {} +} + +function isTrustedDomain(hostname) { + if (!hostname) return false; + const h = hostname.toLowerCase(); + return cachedTrustedDomains.some(d => { + const dom = d.toLowerCase().replace(/^\./, ''); + return h === dom || h.endsWith('.' + dom); + }); +} + +function getBlocker() { + if (blockerPromise) return blockerPromise; + blockerPromise = (async () => { + // Load from cache first (avoids re-downloading on every startup) + if (fs.existsSync(BLOCKER_CACHE_PATH)) { + try { + const data = fs.readFileSync(BLOCKER_CACHE_PATH); + const b = ElectronBlocker.deserialize(new Uint8Array(data)); + console.log('[adblock] loaded from cache'); + return b; + } catch (e) { + console.warn('[adblock] cache invalid, re-downloading:', e.message); + } + } + // Download filter lists (EasyList + EasyPrivacy + uBlock Origin + Russian ad networks) + console.log('[adblock] downloading filter lists...'); + const fetchFn = (url, opts) => getProxySession().fetch(url, opts); + const russianLists = [ + 'https://filters.adtidy.org/extension/ublock/filters/1.txt', // AdGuard Russian + 'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList + ]; + const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]); + // Whitelist domains that need ALL requests passed through unfiltered. + // Tracking-list false positives on these break critical functionality: + // • Google: OAuth/login integrity checks fail without gstatic + analytics endpoints + // → "Возможно, этот браузер или приложение небезопасны" error + // • Yandex/Mail/Microsoft/Apple: same OAuth-style integrity flows + // • TMDB: movie search API and poster CDN + const whitelist = [ + '@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^', + '@@||google.com^', '@@||googleapis.com^', '@@||googleusercontent.com^', + '@@||gstatic.com^', '@@||youtube.com^', '@@||ytimg.com^', '@@||googlevideo.com^', + '@@||google-analytics.com^', '@@||googletagmanager.com^', + '@@||yandex.ru^', '@@||yandex.com^', '@@||yastatic.net^', '@@||mc.yandex.ru^', + '@@||github.com^', '@@||githubassets.com^', '@@||githubusercontent.com^', + '@@||vk.com^', '@@||vk.ru^', '@@||vkuser.net^', + '@@||mail.ru^', '@@||my.mail.ru^', '@@||imgsmail.ru^', + '@@||microsoft.com^', '@@||microsoftonline.com^', '@@||live.com^', '@@||office.com^', + '@@||apple.com^', '@@||icloud.com^', + '@@||facebook.com^', '@@||fbcdn.net^', + ]; + b.updateFromDiff({ added: whitelist }); + fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize())); + console.log('[adblock] filter lists downloaded and cached'); + return b; + })(); + return blockerPromise; +} + +function enableBlockingInSession(sess) { + getBlocker() + .then(b => { + b.enableBlockingInSession(sess); + // Remove the cliqz preload script that the blocker just registered on this + // session. The preload injects inline + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..50b15df --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import HomePage from './pages/HomePage' + +const App: React.FC = () => + +export default App diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx new file mode 100644 index 0000000..e80ab6d --- /dev/null +++ b/src/components/AppCard.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +export interface AppCardProps { + name: string + imageUrl: string + url: string + useProxy?: boolean +} + +const AppCard: React.FC = ({ name, imageUrl, url, useProxy }) => { + const openApp = () => { + window.electron?.createView(name, url, imageUrl, 1.0, useProxy ?? false) + } + + return ( +
+ {imageUrl ? ( + {name} + ) : ( +
{name.charAt(0).toUpperCase()}
+ )} +

{name}

+
+ ) +} + +export default AppCard diff --git a/src/components/AppList.tsx b/src/components/AppList.tsx new file mode 100644 index 0000000..cfaa5c3 --- /dev/null +++ b/src/components/AppList.tsx @@ -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 = ({ apps, bookmarks, onBookmarkOpen, onBookmarkRemove }) => { + return ( +
+ + {apps.length === 0 ? ( +
+

Нет приложений.

+

Откройте настройки (шестерёнка) и добавьте сайты.

+
+ ) : ( + apps.map((card, i) => ( + + )) + )} +
+ ) +} + +export default AppList diff --git a/src/components/BookmarksBar.tsx b/src/components/BookmarksBar.tsx new file mode 100644 index 0000000..024c663 --- /dev/null +++ b/src/components/BookmarksBar.tsx @@ -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 = ({ bookmarks, onOpen, onRemove }) => { + const [expanded, setExpanded] = useState(false) + + if (!bookmarks.length) return null + + return ( +
+
setExpanded(e => !e)}> + + + + Закладки ({bookmarks.length}) + + {expanded ? : } + +
+ +
+
+
+ {bookmarks.map((b, i) => { + const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon + return ( +
onOpen(b)}> +
+ {b.poster + ? {b.title} { + const t = e.currentTarget + t.style.display = 'none' + const ph = t.nextElementSibling as HTMLElement | null + if (ph) ph.style.display = 'flex' + }} /> + : null} +
+ {b.title.charAt(0).toUpperCase()} +
+
+
+
{b.title}
+ {b.source && ( +
+ {hasMoviePoster && b.siteIcon && ( + { e.currentTarget.style.display = 'none' }} /> + )} + {b.source} +
+ )} +
+ +
+ ) + })} +
+
+
+
+ ) +} + +export default BookmarksBar diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..a3540da --- /dev/null +++ b/src/components/Header.tsx @@ -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 = ({ 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('') + const timeoutRef = useRef | 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(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 ( + <> +
+ {(!isCollapsed || isHovered) && ( + <> +
+
+ + {isKiosk ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + +
+
+ + + + +
+
+ +
+ {appOpen && openedFromSearch && onBackToSearch && ( + + )} + {appOpen && ( + <> + + + + + + + )} +
+ +
+ {showSearchIcon && ( + + )} + {appOpen && ( + + )} +
+ + )} +
+ + {updateStatus && ( +
+ {updateStatus.state === 'available' && ( + <> + + Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}… + + )} + {updateStatus.state === 'error' && ( + <> + Ошибка обновления: {updateStatus.message} + + + )} + {updateStatus.state === 'downloading' && ( + <> + Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}% +
+
+
+ + )} + {updateStatus.state === 'ready' && ( + <> + Версия {updateStatus.version} готова к установке + + + )} + {updateStatus.state === 'manual' && ( + <> + Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''} + + + )} + +
+ )} + + {showSettings && ( + + )} + + ) +} + +export default Header diff --git a/src/components/MovieSearch.tsx b/src/components/MovieSearch.tsx new file mode 100644 index 0000000..aa97cb6 --- /dev/null +++ b/src/components/MovieSearch.tsx @@ -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(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 ( +
setOpen(o => !o)}> +
+ + {selected ? selected.label : (placeholder ?? '')} + + + + +
+ {open && ( +
e.stopPropagation()}> + {options.map(o => ( +
{ onChange(o.value); setOpen(false) }} + >{o.label}
+ ))} +
+ )} +
+ ) +} + +// 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(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 ( +
+
setOpen(o => !o)}> + 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label} + {values.length > 0 && ( + + )} + + + +
+ {open && ( +
e.stopPropagation()}> + {options.map(o => { + const on = values.includes(o.value) + return ( +
toggle(o.value)}> + {on && ( + + + + )} + {o.label} +
+ ) + })} +
+ )} +
+ ) +} + +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 = () => ( + + + +) + +const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => ( +
onSelect(movie)} + > +
+ {movie.poster + ? {movie.title} { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} /> + : null + } +
+ {movie.title.charAt(0).toUpperCase()} +
+
+
+ {movie.title} +
+ {movie.year && {movie.year}} + {movie.rating && {movie.rating}} +
+ {movie.mediaType === 'tv' && Сериал} +
+
+) + +const MovieSearch: React.FC = ({ 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([]) + const [years, setYears] = useState([]) + const [minRating, setMinRating] = useState('') + const [countries, setCountries] = useState([]) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + + const [activeQuery, setActiveQuery] = useState(initialQuery) // committed query (on Enter) + const [tmdbResults, setTmdbResults] = useState([]) + const [selected, setSelected] = useState(null) + const [siteResults, setSiteResults] = useState([]) + 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([]) + 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() + 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 ( +
+ + {/* Search bar */} +
+ setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !loading && handleSearch()} + autoFocus + /> + {query && ( + + )} + +
+ + {/* Filters (only in discover mode with TMDB key) */} + {apiKey && !selected && ( +
+
+ {/* Type toggle */} +
+ + +
+ + {/* Sort */} + {!isSearchMode && ( + handleFilterChange(() => setMinRating(v))} + options={RATINGS.map(r => ({ value: r.value, label: r.label === 'Любой' ? 'Рейтинг' : r.label }))} + placeholder="Рейтинг" + /> + )} + + {/* Years (multi, OR) */} + handleFilterChange(() => setYears(v))} + options={YEARS.map(y => ({ value: String(y), label: String(y) }))} + placeholder="Год" + /> + + {/* Countries (multi, OR) */} + {!isSearchMode && ( + handleFilterChange(() => setCountries(v))} + options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))} + placeholder="Страна" + /> + )} +
+ + {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */} + {!isSearchMode && ( +
+ + {genres.map(g => ( + + ))} + {genreIds.length > 1 && ( + все выбранные одновременно + )} +
+ )} +
+ )} + + {message &&

{message}

} + + {/* Detail view */} + {selected ? ( +
+ {selected.poster && ( +
+ +
+
+ )} +
+ +
+ {selected.poster + ? {selected.title} + :
{selected.title.charAt(0)}
+ } +
+

{selected.title}

+ {selected.originalTitle !== selected.title && ( +

{selected.originalTitle}

+ )} +
+ {selected.year && {selected.year}} + {selected.mediaType === 'tv' && Сериал} + {selected.rating && {selected.rating}} +
+ {selected.overview &&

{selected.overview}

} +
+
+ +
+ {sitesLoading &&

Ищем на {sites.length} сайтах...

} + {!sitesLoading && !siteResults.length && !message &&

Поиск...

} + {!sitesLoading && siteResults.length > 0 && ( +
+ Найдено на сайтах + +
+ )} + {!sitesLoading && !siteResults.length && ( + + )} + {siteResults.map((r, i) => ( +
onOpenUrl(r.title, r.url)}> + {r.source} + {r.title} +
+ {onBookmark && ( + + )} + Открыть → +
+
+ ))} +
+
+
+ ) : ( + <> + {tmdbLoading &&

{isSearchMode ? 'Поиск...' : 'Загрузка...'}

} + + {tmdbResults.length > 0 && ( + <> +
+ {tmdbResults.map((movie, i) => )} +
+ {!isSearchMode && page < totalPages && ( + + )} + + )} + + {/* Direct site results (no TMDB key) */} + {siteResults.length > 0 && ( +
+ {siteResults.map((r, i) => ( +
onOpenUrl(r.title, r.url)}> +
+ {r.poster + ? {r.title} { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} /> + : null + } +
{r.title.charAt(0).toUpperCase()}
+
+
+ {r.title} + {r.year && {r.year}} + {r.source} +
+
+ ))} +
+ )} + + )} +
+ ) +} + +export default MovieSearch diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx new file mode 100644 index 0000000..a9ddd02 --- /dev/null +++ b/src/components/Settings.tsx @@ -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 = ({ onClose, onAppsChange }) => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [newApp, setNewApp] = useState({ 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({ 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 ( +
+
e.stopPropagation()}> +
+

Настройки

+ +
+ +
+

Прокси

+
+ updateProxy('host', e.target.value)} + /> + : + updateProxy('port', e.target.value)} + /> +
+
+ http_proxy=http://{settings.proxy.host}:{settings.proxy.port} + https_proxy=http://{settings.proxy.host}:{settings.proxy.port} + socks5://{settings.proxy.host}:{settings.proxy.port} +
+

+ Прокси применяется к каждому сайту индивидуально — переключатель рядом с каждым приложением. +

+
+ +
+
+

Доверенные домены

+ +
+

+ Переходы и popup'ы на эти домены открываются без подтверждения — нужно для входа через Google, Яндекс, GitHub и т.п. + Совпадение по суффиксу: google.com разрешит и accounts.google.com, и www.google.com. +

+
+ {trustedDomains.map((d, i) => ( + + {d} + + + ))} + {trustedDomains.length === 0 &&

Список пуст.

} +
+
+ setNewTrusted(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addTrustedDomain()} + /> + +
+
+ +
+

Приложения

+
+ {settings.apps.map((app, i) => ( +
+
+ {app.imageUrl && ( + {app.name} + )} +
+ {app.name} + {app.url} +
+
+
+
+ ))} + {settings.apps.length === 0 && ( +

Нет приложений. Добавьте ниже.

+ )} +
+ +
+

Добавить приложение

+ setNewApp({ ...newApp, name: e.target.value })} + /> + setNewApp({ ...newApp, url: e.target.value })} + /> + setNewApp({ ...newApp, imageUrl: e.target.value })} + /> +
+ +
+

Поиск фильмов

+

TMDB API ключ

+ { + const updated = { ...settings, tmdbApiKey: e.target.value } + setSettings(updated) + saveSettings(updated) + }} + /> +

Нужен для постеров и метаданных. Без ключа поиск работает напрямую по сайтам.

+

Сайты

+

Тип определяется автоматически. Поддерживаются: kinogo, lordfilm, gidonline, hdrezka, filmix и их зеркала. Домен без https://

+
+ {(settings.movieSites ?? []).map((site, i) => ( +
+
+
+ {site.domain} + {site.type} +
+
+
+
+ ))} + {!(settings.movieSites ?? []).length && ( +

Нет сайтов. Добавьте ниже.

+ )} +
+
+

Добавить сайт

+ { + const domain = e.target.value + setNewSite({ ...newSite, domain, type: guessMovieSiteType(domain) }) + }} + /> + + +
+
+
+
+ ) +} + +export default Settings diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..5b34149 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -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 = ({ openedApps, activeApp, setActiveApp }) => { + const [expanded, setExpanded] = useState(false) + const timeoutRef = useRef | 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 ( +
+
+ + + + + Домой +
+ + {openedApps.map(app => ( + + ))} +
+ ) +} + +export default Sidebar diff --git a/src/components/SidebarElement.tsx b/src/components/SidebarElement.tsx new file mode 100644 index 0000000..1afa94b --- /dev/null +++ b/src/components/SidebarElement.tsx @@ -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 = ({ name, imageUrl, isActive, onClick }) => { + return ( +
+ {imageUrl ? ( + {name} + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ )} + {name} +
+ ) +} + +export default SidebarElement diff --git a/src/entries/dialog-confirm.tsx b/src/entries/dialog-confirm.tsx new file mode 100644 index 0000000..4eae6d0 --- /dev/null +++ b/src/entries/dialog-confirm.tsx @@ -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 ( +
+ {text &&
{text}
} +
+ + +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/src/entries/dialog-error.tsx b/src/entries/dialog-error.tsx new file mode 100644 index 0000000..d37d195 --- /dev/null +++ b/src/entries/dialog-error.tsx @@ -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 ( +
+ {title &&
{title}
} + {text &&
{text}
} +
+ +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/src/entries/loader.tsx b/src/entries/loader.tsx new file mode 100644 index 0000000..51f1749 --- /dev/null +++ b/src/entries/loader.tsx @@ -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
+} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/src/entries/sidebar.tsx b/src/entries/sidebar.tsx new file mode 100644 index 0000000..027fa0c --- /dev/null +++ b/src/entries/sidebar.tsx @@ -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([]) + const [activeApp, setActiveApp] = useState('home') + const [expanded, setExpanded] = useState(false) + const timeoutRef = useRef | 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 ( +
+
+ + + + + Домой +
+ + {openedApps.map(app => ( +
window.electron?.showView(app.name)} + > + {app.imageUrl ? ( + {app.name} + ) : ( +
+ {app.name.charAt(0).toUpperCase()} +
+ )} + {app.name} +
+ ))} +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..d689d9e --- /dev/null +++ b/src/main.tsx @@ -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 + writeConfig: (data: any) => void + searchMovies: (query: string, sites: any[]) => Promise + 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 + checkUpdateNow: () => Promise + toggleKiosk: () => Promise + isKiosk: () => Promise + } + } +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..058e287 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -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([]) + const [activeApp, setActiveApp] = useState('home') + const [appCardList, setAppCardList] = useState([]) + const [movieQuery, setMovieQuery] = useState(null) + const [movieSearchKey, setMovieSearchKey] = useState(0) + const [openedFromSearch, setOpenedFromSearch] = useState(false) + const [bookmarks, setBookmarks] = useState([]) + const configRef = useRef({}) + + 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 ( + <> +
+ +
+ +
+ {activeApp !== 'movie-search' && ( + + )} + + ) +} + +export default HomePage diff --git a/src/styles/dialogs.css b/src/styles/dialogs.css new file mode 100644 index 0000000..28c4bf1 --- /dev/null +++ b/src/styles/dialogs.css @@ -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; } diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..3b344b1 --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,1534 @@ +/* ---- Reset & Base ---- */ +*, *::before, *::after { box-sizing: border-box; } + +:root { + --accent: #E50914; + --accent-dim: rgba(229,9,20,0.15); + --bg: #111; + --bg-card: #1a1a1a; + --bg-hover: #252525; + --bg-elevated: #222; + --text: #fff; + --text-sub: #aaa; + --text-dim: #555; + --border: rgba(255,255,255,0.07); + --radius: 6px; + --header-h: 50px; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; + user-select: none; +} + +/* ---- Header ---- */ +.header { + position: fixed; + top: 0; left: 0; right: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + transition: height 0.3s ease; + overflow: hidden; + background: rgba(0,0,0,0.92); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); +} + +.header.expanded { height: var(--header-h); } +.header.collapsed { height: 2px; background: #000; border: none; } + +.header-left, +.header-center, +.header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.header-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + background: transparent; + color: #999; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.15s, color 0.15s; + padding: 0; +} + +.header-btn:hover { background: rgba(255,255,255,0.08); color: #fff; } + +.nav-btn { color: #ccc; } +.nav-btn.disabled { color: #383838; cursor: default; } +.nav-btn.disabled:hover { background: transparent; color: #383838; } + +.header-search { + display: flex; + align-items: center; + gap: 6px; + width: 360px; +} + +.header-search-input { + flex: 1; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius); + color: #fff; + padding: 7px 12px; + font-size: 13px; + outline: none; + transition: border-color 0.2s, background 0.2s; +} + +.header-search-input::placeholder { color: #666; } +.header-search-input:focus { + border-color: rgba(255,255,255,0.25); + background: rgba(255,255,255,0.09); +} + +.header-search-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: rgba(255,255,255,0.07); + color: #888; + border-radius: var(--radius); + cursor: pointer; + padding: 0; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +.header-search-btn:hover { background: rgba(255,255,255,0.13); color: #fff; } + +.header-close-btn { + color: #888; +} + +.header-close-btn:hover { + background: rgba(220, 50, 50, 0.15) !important; + color: #e05555 !important; +} + +/* ---- Sidebar ---- */ +.sidebar { + position: fixed; + left: 0; + top: var(--header-h); + width: 75px; + height: calc(100vh - var(--header-h)); + background: rgba(0,0,0,0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + z-index: 150; + padding-top: 6px; + transition: width 0.22s cubic-bezier(0.4,0,0.2,1); + overflow: hidden; + border-right: 1px solid var(--border); +} + +.sidebar.expanded { width: 200px; } + +.sidebar-item { + display: flex; + align-items: center; + gap: 13px; + padding: 9px 14px; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; + position: relative; +} + +.sidebar-item::before { + content: ''; + position: absolute; + left: 0; top: 6px; bottom: 6px; + width: 3px; + background: var(--accent); + border-radius: 0 2px 2px 0; + opacity: 0; + transition: opacity 0.15s; +} + +.sidebar-item:hover { background: rgba(255,255,255,0.06); } +.sidebar-item.active { background: rgba(255,255,255,0.09); } +.sidebar-item.active::before { opacity: 1; } + +.sidebar-icon { + width: 26px; + height: 26px; + flex-shrink: 0; + color: #666; + transition: color 0.15s; +} + +.sidebar-item:hover .sidebar-icon, +.sidebar-item.active .sidebar-icon { color: #ddd; } + +.sidebar-app-icon { + width: 34px; + height: 34px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; +} + +.sidebar-app-icon-placeholder { + width: 34px; + height: 34px; + border-radius: 8px; + background: #2a2a2a; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; + color: #bbb; + flex-shrink: 0; +} + +.sidebar-item span { + font-size: 13px; + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.sidebar.collapsed .sidebar-item span { display: none; } + +/* ---- App List ---- */ +.app-list { + position: fixed; + top: var(--header-h); + left: 75px; + right: 0; + bottom: 0; + padding: 28px 32px; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 14px; + scrollbar-width: thin; + scrollbar-color: #2a2a2a transparent; +} + +.app-list::-webkit-scrollbar { width: 6px; } +.app-list::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 3px; } + +.app-list-empty { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #444; + font-size: 14px; + text-align: center; + gap: 6px; +} + +/* ---- App Card ---- */ +.app-card { + width: 155px; + background: var(--bg-card); + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.22s ease, background 0.15s; + gap: 10px; + padding: 20px 14px 16px; + border: 1px solid var(--border); +} + +.app-card:hover { + transform: scale(1.07) translateY(-2px); + background: var(--bg-hover); + box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1); +} + +.app-card img { + width: 76px; + height: 76px; + object-fit: contain; + border-radius: 12px; + transition: transform 0.2s; +} + +.app-card:hover img { transform: scale(1.05); } + +.app-card-icon-placeholder { + width: 76px; + height: 76px; + border-radius: 12px; + background: linear-gradient(135deg, #2a2a2a 0%, #333 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + font-weight: 700; + color: #bbb; +} + +.app-card h3 { + margin: 0; + font-size: 12px; + font-weight: 600; + color: #ccc; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + transition: color 0.15s; +} + +.app-card:hover h3 { color: #fff; } + +/* ---- Settings ---- */ +.settings-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.82); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } +@keyframes cardAppear { from { opacity: 0; transform: translateY(14px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } + +.settings-panel { + background: #161616; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + width: 570px; + max-height: 86vh; + overflow-y: auto; + padding: 28px; + color: #ddd; + animation: slideUp 0.2s ease; + scrollbar-width: thin; + scrollbar-color: #2a2a2a transparent; +} + +.settings-panel::-webkit-scrollbar { width: 4px; } +.settings-panel::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 2px; } + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; +} + +.settings-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #fff; +} + +.settings-close { + background: none; + border: none; + color: #666; + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; + transition: color 0.15s, background 0.15s; +} + +.settings-close:hover { background: rgba(255,255,255,0.07); color: #fff; } + +.settings-section { margin-bottom: 30px; } + +.settings-section h3 { + margin: 0 0 14px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #555; + font-weight: 600; +} + +.settings-section h4 { + margin: 16px 0 10px; + font-size: 13px; + color: #888; +} + +.proxy-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.proxy-colon { color: #555; font-size: 18px; } + +.settings-input { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius); + color: #eee; + padding: 9px 12px; + font-size: 13px; + flex: 1; + outline: none; + transition: border-color 0.2s, background 0.2s; +} + +.settings-input:focus { border-color: rgba(255,255,255,0.28); background: rgba(255,255,255,0.07); } +.proxy-port { flex: 0 0 80px; } + +.proxy-info { + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(0,0,0,0.5); + border: 1px solid rgba(255,255,255,0.05); + border-radius: var(--radius); + padding: 10px 12px; + margin-bottom: 8px; +} + +.proxy-info code { font-size: 12px; color: #7ec8a0; font-family: monospace; } +.proxy-hint { font-size: 12px; color: #555; margin: 6px 0 0; } + +.settings-apps-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.settings-app-row { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255,255,255,0.04); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + gap: 12px; + transition: background 0.15s; +} + +.settings-app-row:hover { background: rgba(255,255,255,0.06); } + +.settings-app-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.settings-app-icon { + width: 30px; + height: 30px; + border-radius: 6px; + object-fit: contain; + flex-shrink: 0; +} + +.settings-app-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.settings-app-name { font-size: 13px; font-weight: 600; color: #eee; } +.settings-app-url { + font-size: 11px; + color: #555; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} + +.settings-app-actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.proxy-switch-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #777; + cursor: pointer; +} + +.proxy-switch { + width: 36px; + height: 20px; + border-radius: 10px; + position: relative; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; +} + +.proxy-switch::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + top: 3px; + transition: left 0.2s; +} + +.proxy-switch.off { background: #333; } +.proxy-switch.off::after { left: 3px; } +.proxy-switch.on { background: var(--accent); } +.proxy-switch.on::after { left: 19px; } + +.settings-remove-btn { + background: none; + border: none; + color: #c04040; + font-size: 15px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + transition: background 0.15s; +} + +.settings-remove-btn:hover { background: rgba(192,64,64,0.15); } + +.settings-empty { + color: #444; + font-size: 13px; + text-align: center; + padding: 14px; +} + +.add-app-form { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.add-proxy-label { margin-top: 4px; } + +.settings-add-btn { + background: var(--accent); + border: none; + border-radius: var(--radius); + color: #fff; + padding: 10px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + margin-top: 4px; + transition: background 0.15s, transform 0.1s; + letter-spacing: 0.03em; +} + +.settings-add-btn:hover { background: #f40612; } +.settings-add-btn:active { transform: scale(0.98); } + +.settings-select { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius); + color: #eee; + padding: 9px 12px; + font-size: 13px; + outline: none; + cursor: pointer; +} + +/* ---- Movie Search ---- */ +.movie-search { + position: fixed; + top: var(--header-h); + left: 75px; + right: 0; + bottom: 0; + padding: 28px 36px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + scrollbar-width: thin; + scrollbar-color: #2a2a2a transparent; +} + +.movie-search::-webkit-scrollbar { width: 6px; } +.movie-search::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 3px; } + +.movie-search-bar { + display: flex; + gap: 8px; + max-width: 620px; +} + +.movie-search-input { + flex: 1; + font-size: 15px; + padding: 11px 16px; + border-radius: var(--radius); + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + outline: none; + transition: border-color 0.2s, background 0.2s; +} + +.movie-search-input::placeholder { color: #555; } +.movie-search-input:focus { border-color: rgba(255,255,255,0.25); background: rgba(255,255,255,0.09); } + +.movie-search-btn { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius); + background: var(--accent); + border: none; + color: #fff; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + flex-shrink: 0; +} + +.movie-search-btn:hover { background: #f40612; } +.movie-search-btn:active { transform: scale(0.95); } +.movie-search-btn:disabled { background: #333; cursor: default; } + +.movie-search-message { font-size: 13px; color: #c04040; margin: 0; } + +/* ---- Search Filters ---- */ +.ms-filters { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 0 4px; +} + +.ms-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.ms-type-toggle { + display: flex; + background: rgba(255,255,255,0.05); + border-radius: 8px; + padding: 3px; + flex-shrink: 0; +} + +.ms-type-btn { + background: transparent; + border: none; + color: #666; + font-size: 13px; + font-weight: 500; + padding: 5px 16px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.ms-type-btn.active { + background: var(--accent); + color: #fff; +} + +/* Custom Select */ +.ms-select { + position: relative; + cursor: pointer; + user-select: none; + flex-shrink: 0; +} + +.ms-select-trigger { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid var(--border); + border-radius: 7px; + color: #888; + font-size: 12px; + padding: 6px 10px; + transition: border-color 0.15s, background 0.15s, color 0.15s; + white-space: nowrap; +} + +.ms-select-trigger svg { + flex-shrink: 0; + transition: transform 0.2s cubic-bezier(0.4,0,0.2,1); + opacity: 0.6; +} + +.ms-select:hover .ms-select-trigger { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.18); + color: #bbb; +} + +.ms-select.open .ms-select-trigger { + border-color: var(--accent); + background: rgba(229,9,20,0.08); + color: #eee; +} + +.ms-select.open .ms-select-trigger svg { + transform: rotate(180deg); +} + +.ms-select-placeholder { color: #666; } +.ms-select-active { color: #ddd; } + +.ms-select-dropdown { + position: absolute; + top: calc(100% + 5px); + left: 0; + min-width: 100%; + background: #1a1a1a; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 9px; + box-shadow: 0 12px 40px rgba(0,0,0,0.7); + z-index: 200; + overflow-y: auto; + max-height: 220px; + animation: dropdownAppear 0.16s cubic-bezier(0.4,0,0.2,1); + scrollbar-width: thin; + scrollbar-color: #333 transparent; +} + +@keyframes dropdownAppear { + from { opacity: 0; transform: translateY(-6px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.ms-select-opt { + padding: 8px 14px; + font-size: 12px; + color: #888; + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; +} + +.ms-select-opt:first-child { border-radius: 9px 9px 0 0; } +.ms-select-opt:last-child { border-radius: 0 0 9px 9px; } +.ms-select-opt:hover { background: rgba(255,255,255,0.06); color: #eee; } +.ms-select-opt.active { color: var(--accent); font-weight: 600; } + +.ms-genres { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.ms-genre-chip { + background: rgba(255,255,255,0.06); + border: 1px solid var(--border); + border-radius: 20px; + color: #888; + font-size: 11px; + padding: 4px 12px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; } +.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +.ms-genres-hint { + font-size: 10px; + color: #666; + font-style: italic; + align-self: center; + margin-left: 6px; +} + +/* Multi-select dropdown */ +.ms-multi-dropdown { overflow-y: auto; } +.ms-multi-opt { display: flex; align-items: center; gap: 8px; } +.ms-multi-opt.active { color: #eee; font-weight: 500; } +.ms-checkbox { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1.5px solid #555; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; +} +.ms-checkbox.on { background: var(--accent); border-color: var(--accent); } + +.ms-multi-clear { + background: transparent; + border: none; + color: #777; + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + margin-left: auto; + margin-right: 2px; +} +.ms-multi-clear:hover { color: #fff; } + +.ms-load-more { + display: block; + margin: 20px auto 8px; + background: rgba(255,255,255,0.07); + border: 1px solid var(--border); + border-radius: 8px; + color: #aaa; + font-size: 13px; + padding: 10px 32px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.ms-load-more:hover { background: rgba(255,255,255,0.12); color: #fff; } +.ms-load-more:disabled { opacity: 0.5; cursor: default; } + +.ms-clear-btn { + background: none; + border: none; + color: #555; + cursor: pointer; + padding: 0 6px; + display: flex; + align-items: center; + transition: color 0.15s; +} +.ms-clear-btn:hover { color: #aaa; } + +.nav-btn.active svg { stroke: var(--accent); } + +/* ---- TMDB Results Grid ---- */ +.movie-results { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-content: flex-start; +} + +.movie-result-card { + display: flex; + flex-direction: column; + width: 135px; + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.22s ease; + border: 1px solid var(--border); + animation: cardAppear 0.3s ease both; +} + +.movie-result-card:hover { + transform: scale(1.07) translateY(-3px); + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + z-index: 5; +} + +.movie-result-poster { + width: 135px; + height: 202px; + overflow: hidden; + background: #1a1a1a; + flex-shrink: 0; + position: relative; +} + +.movie-result-poster img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.3s ease; +} + +.movie-result-card:hover .movie-result-poster img { transform: scale(1.04); } + +.movie-result-poster-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 44px; + font-weight: 700; + color: #2a2a2a; + background: linear-gradient(135deg, #161616 0%, #222 100%); +} + +.movie-result-info { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 3px; } + +.movie-result-title { + font-size: 12px; + font-weight: 600; + color: #ddd; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.4; +} + +.movie-result-year { font-size: 11px; color: #666; } +.movie-result-source { font-size: 10px; color: #444; margin-top: 1px; } + +.ms-card-meta { display: flex; gap: 8px; align-items: center; margin-top: 1px; } +.ms-card-rating { font-size: 11px; color: #f5c518; display: flex; align-items: center; gap: 2px; } +.ms-card-type { + font-size: 10px; + color: #aaa; + background: rgba(255,255,255,0.1); + padding: 1px 5px; + border-radius: 3px; +} + +/* ---- Movie Detail ---- */ +.ms-detail { + position: relative; + min-height: 100%; +} + +/* Blurred poster background */ +.ms-detail-bg { + position: fixed; + inset: 0; + top: var(--header-h); + left: 75px; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.ms-detail-bg-img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + filter: blur(32px) saturate(0.6) brightness(0.35); + transform: scale(1.08); +} + +.ms-detail-bg-gradient { + position: absolute; + inset: 0; + background: linear-gradient(to right, rgba(17,17,17,0.92) 0%, rgba(17,17,17,0.6) 40%, rgba(17,17,17,0.4) 100%), + linear-gradient(to bottom, rgba(17,17,17,0.4) 0%, rgba(17,17,17,0.85) 100%); +} + +.ms-detail-content { + position: relative; + z-index: 1; +} + +.ms-back-btn { + background: rgba(255,255,255,0.06); + border: 1px solid var(--border); + color: #aaa; + font-size: 13px; + cursor: pointer; + padding: 6px 14px; + margin-bottom: 24px; + border-radius: var(--radius); + transition: background 0.15s, color 0.15s; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; +} + +.ms-back-btn:hover { background: rgba(255,255,255,0.1); color: #fff; } + +.ms-detail-card { + display: flex; + gap: 28px; + margin-bottom: 32px; + max-width: 760px; +} + +.ms-detail-poster { + width: 160px; + height: 240px; + border-radius: 10px; + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 20px 60px rgba(0,0,0,0.8); +} + +.ms-poster-placeholder { + width: 160px; + height: 240px; + border-radius: 10px; + background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 52px; + font-weight: 700; + color: #333; + flex-shrink: 0; +} + +.ms-detail-info { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 4px; +} + +.ms-detail-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #fff; + line-height: 1.15; + letter-spacing: -0.02em; +} + +.ms-detail-orig { margin: 0; font-size: 13px; color: #666; } + +.ms-detail-meta { + display: flex; + gap: 14px; + font-size: 13px; + color: #999; + align-items: center; + flex-wrap: wrap; +} + +.ms-detail-meta span { + display: flex; + align-items: center; + gap: 3px; +} + +.ms-detail-overview { + margin: 4px 0 0; + font-size: 13px; + color: #aaa; + line-height: 1.65; + max-width: 500px; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ---- Site Results ---- */ +.ms-sites-label { + font-size: 11px; + font-weight: 700; + color: #555; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 10px; +} + +.ms-site-results { + display: flex; + flex-direction: column; + gap: 5px; + max-width: 760px; +} + +.ms-site-row { + display: flex; + align-items: center; + gap: 14px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 13px 18px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.15s; +} + +.ms-site-row:hover { + background: rgba(255,255,255,0.09); + border-color: rgba(255,255,255,0.12); + transform: translateX(2px); +} + +.ms-site-source { + font-size: 11px; + color: #555; + width: 130px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-site-title { + flex: 1; + font-size: 13px; + font-weight: 500; + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-site-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + +.ms-site-open { + font-size: 12px; + color: #444; + flex-shrink: 0; + transition: color 0.15s; +} + +.ms-site-row:hover .ms-site-open { color: #999; } + +.ms-bookmark-btn { + background: none; + border: none; + color: #444; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + display: flex; + align-items: center; + transition: color 0.15s, background 0.15s; +} + +.ms-bookmark-btn:hover { color: #f5c518; background: rgba(245,197,24,0.1); } + +/* ---- Bookmarks Bar ---- */ +.bookmarks-bar { + width: 100%; + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 16px; + overflow: hidden; + transition: background 0.15s; +} + +.bookmarks-bar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + cursor: pointer; + font-size: 13px; + color: #666; + transition: background 0.15s, color 0.15s; + user-select: none; +} + +.bookmarks-bar-header:hover { background: rgba(255,255,255,0.04); color: #bbb; } +.bookmarks-bar-header span { flex: 1; font-weight: 500; } + +.bookmarks-collapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.32s cubic-bezier(0.4,0,0.2,1); +} + +.bookmarks-collapse.open { + grid-template-rows: 1fr; +} + +.bookmarks-collapse-inner { + overflow: hidden; +} + +.bookmarks-list { + display: flex; + gap: 10px; + padding: 0 14px 14px; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: #2a2a2a transparent; +} + +.bookmarks-list::-webkit-scrollbar { height: 4px; } +.bookmarks-list::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 2px; } + +.bookmark-card { + display: flex; + flex-direction: column; + width: 105px; + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + position: relative; + flex-shrink: 0; + border: 1px solid var(--border); + transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s ease; +} + +.bookmark-card:hover { + transform: scale(1.07) translateY(-2px); + box-shadow: 0 12px 36px rgba(0,0,0,0.6); + z-index: 2; +} + +.bookmark-poster { + width: 105px; + height: 148px; + overflow: hidden; + background: #1a1a1a; + flex-shrink: 0; +} + +.bookmark-poster img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.bookmark-card:hover .bookmark-poster img { transform: scale(1.06); } + +.bookmark-poster-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 34px; + font-weight: 700; + color: #2a2a2a; + background: linear-gradient(135deg, #161616 0%, #222 100%); +} + +.bookmark-info { padding: 7px 8px 8px; display: flex; flex-direction: column; gap: 2px; } + +.bookmark-title { + font-size: 11px; + font-weight: 500; + color: #ccc; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.35; +} + +.bookmark-source { font-size: 10px; color: #777; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.bookmark-source-row { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.bookmark-source-icon { + width: 12px; + height: 12px; + border-radius: 2px; + object-fit: contain; + flex-shrink: 0; +} + +.bookmark-remove { + position: absolute; + top: 5px; + right: 5px; + background: rgba(0,0,0,0.72); + border: none; + color: #999; + font-size: 10px; + cursor: pointer; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + line-height: 1; + padding: 0; +} + +.bookmark-card:hover .bookmark-remove { opacity: 1; } +.bookmark-remove:hover { color: #fff; background: rgba(200,40,40,0.85); } + +/* ---- Trusted Domains ---- */ +.settings-section-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.settings-reset-btn { + background: transparent; + border: 1px solid var(--border); + color: #888; + font-size: 11px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.settings-reset-btn:hover { color: #ccc; border-color: #555; } + +.trusted-domains-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.trusted-domain-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(255,255,255,0.04); + border: 1px solid var(--border); + border-radius: 12px; + padding: 3px 4px 3px 10px; + font-size: 11px; + color: #bbb; + line-height: 1; +} + +.trusted-domain-remove { + background: transparent; + border: none; + color: #666; + cursor: pointer; + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + line-height: 1; + padding: 0; +} +.trusted-domain-remove:hover { color: #fff; background: rgba(200,40,40,0.7); } + +.trusted-domain-add { + display: flex; + gap: 6px; + align-items: center; +} +.trusted-domain-add .settings-input { flex: 1; } + +/* ---- Modal Dialog ---- */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0); + transition: background 0.22s ease; +} + +.modal-overlay.visible { + background: rgba(0,0,0,0.78); +} + +.modal-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); +} + +.modal-overlay.visible .modal-card { + opacity: 1; + transform: scale(1) translateY(0); +} + +.modal-title { + font-size: 17px; + font-weight: 700; + color: #fff; + margin-bottom: 10px; +} + +.modal-msg { + font-size: 13px; + color: #999; + line-height: 1.5; + margin-bottom: 26px; +} + +.modal-btns { + display: flex; + gap: 10px; + justify-content: center; +} + +.modal-btn { + padding: 10px 26px; + border: none; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s; +} + +.modal-btn:hover { opacity: 0.85; } +.modal-btn:active { transform: scale(0.97); } +.modal-btn-yes { background: #E50914; color: #fff; } +.modal-btn-no { background: rgba(255,255,255,0.1); color: #ccc; } +.modal-btn-ok { background: rgba(255,255,255,0.1); color: #ccc; } + +/* ---- Update banner ---- */ +.update-banner { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 9999; + background: #1a1a1a; + border: 1px solid rgba(229,9,20,0.4); + border-radius: 8px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #fff; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); +} +.update-banner-btn { + background: #E50914; + color: #fff; + border: none; + border-radius: 5px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} +.update-banner-close { + background: none; + border: none; + color: #777; + cursor: pointer; + font-size: 13px; + padding: 0; + line-height: 1; +} +.update-banner-close:hover { color: #fff; } + +.update-banner.error { + border-color: rgba(229,9,20,0.5); + background: rgba(40,10,12,0.95); +} + +.update-banner-progress { + flex: 1; + height: 4px; + background: rgba(255,255,255,0.08); + border-radius: 2px; + overflow: hidden; + min-width: 80px; + max-width: 200px; +} +.update-banner-progress-bar { + height: 100%; + background: linear-gradient(90deg, #E50914, #ff5252); + transition: width 0.3s ease; +} + +.update-banner-spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(255,255,255,0.15); + border-top-color: #E50914; + border-radius: 50%; + animation: ub-spin 0.8s linear infinite; + display: inline-block; +} +@keyframes ub-spin { to { transform: rotate(360deg); } } + +/* ---- Retry btn ---- */ +.ms-retry-btn { + background: none; + border: none; + color: #aaa; + cursor: pointer; + padding: 2px 6px; + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 4px; + transition: color 0.15s; +} +.ms-retry-btn:hover { color: #fff; } +.ms-retry-standalone { + margin-top: 12px; + font-size: 13px; + color: #E50914; + display: block; +} +.ms-retry-standalone:hover { color: #ff4444; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..39757a5 --- /dev/null +++ b/vite.config.ts @@ -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'], + }, +})