init: media-center v2
Rewrite of ESH-Media v1 with separated main/renderer/shared architecture (vite-plugin-electron, React 18, react-router-dom). Includes NeDB storage, electron-store config, proxy manager with FoxyProxy/uBlock extensions, custom server-checked updater, NSIS installer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
release/
|
||||
out/
|
||||
|
||||
# Electron
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
.electron/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
data/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
235
QUICKSTART.md
Normal file
235
QUICKSTART.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Quick Start Guide
|
||||
|
||||
Быстрый старт для Media Center v2
|
||||
|
||||
## 1. Установка
|
||||
|
||||
```bash
|
||||
# Клонировать или перейти в директорию проекта
|
||||
cd media_center_v2
|
||||
|
||||
# Установить зависимости
|
||||
npm install
|
||||
```
|
||||
|
||||
## 2. Запуск в режиме разработки
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Это запустит приложение в режиме разработки с hot reload.
|
||||
|
||||
## 3. Первый запуск
|
||||
|
||||
После запуска приложения:
|
||||
|
||||
1. **Главная страница** откроется автоматически с карточками сайтов
|
||||
2. **Прокси** запустится автоматически (если настроен)
|
||||
3. **Попробуйте поиск**: введите название фильма в поисковую строку
|
||||
|
||||
## 4. Тестирование поиска
|
||||
|
||||
1. В верхней части окна введите "Стражи галактики"
|
||||
2. Нажмите "Найти"
|
||||
3. Дождитесь результатов со всех сайтов
|
||||
4. Кликните на понравившийся результат
|
||||
|
||||
## 5. Сборка установщика
|
||||
|
||||
```bash
|
||||
# Для Windows
|
||||
npm run package:win
|
||||
|
||||
# Для macOS
|
||||
npm run package:mac
|
||||
|
||||
# Для Linux
|
||||
npm run package:linux
|
||||
```
|
||||
|
||||
Установщик будет в папке `release/`.
|
||||
|
||||
## Структура файлов
|
||||
|
||||
### Важные файлы для начала:
|
||||
|
||||
- `config/sites.json` - настройки сайтов
|
||||
- `search-scripts/` - скрипты поиска для каждого сайта
|
||||
- `src/shared/types.ts` - TypeScript типы
|
||||
- `src/shared/constants.ts` - константы (URL сервера, порты и т.д.)
|
||||
|
||||
### Изменение настроек:
|
||||
|
||||
**Сервер конфигураций:**
|
||||
```typescript
|
||||
// src/shared/constants.ts
|
||||
export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api';
|
||||
```
|
||||
|
||||
**Порт прокси:**
|
||||
```typescript
|
||||
// src/shared/constants.ts
|
||||
export const DEFAULT_PROXY_PORT = 10808;
|
||||
```
|
||||
|
||||
**Путь к InvisibleManXRay:**
|
||||
```typescript
|
||||
// src/shared/constants.ts
|
||||
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay';
|
||||
```
|
||||
|
||||
## Добавление нового сайта
|
||||
|
||||
### Шаг 1: Создать скрипт поиска
|
||||
|
||||
Скопируйте `search-scripts/SCRIPT_TEMPLATE.js` → `search-scripts/mysite.js`
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
const response = await axios.get(`${siteUrl}/search?q=${query}`);
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
const results = [];
|
||||
$('.movie').each((i, el) => {
|
||||
results.push({
|
||||
name: $(el).find('.title').text(),
|
||||
url: siteUrl + $(el).find('a').attr('href'),
|
||||
image: $(el).find('img').attr('src')
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
### Шаг 2: Добавить в конфигурацию
|
||||
|
||||
Отредактируйте `config/sites.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mysite",
|
||||
"name": "My Site",
|
||||
"url": "https://mysite.com",
|
||||
"logo": "https://mysite.com/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": false,
|
||||
"searchScript": "mysite.js"
|
||||
}
|
||||
```
|
||||
|
||||
### Шаг 3: Перезапустить приложение
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
### Открыть DevTools
|
||||
|
||||
Нажмите `F12` в приложении
|
||||
|
||||
### Проверить логи
|
||||
|
||||
**Main process (терминал):**
|
||||
```
|
||||
[Proxy STDOUT]: ...
|
||||
Found 5 results from Kinogo
|
||||
```
|
||||
|
||||
**Renderer process (DevTools Console):**
|
||||
```javascript
|
||||
console.log('Search results:', results);
|
||||
```
|
||||
|
||||
### Проверить ошибки скриптов
|
||||
|
||||
После выполнения поиска проверьте консоль на наличие сообщений вида:
|
||||
```
|
||||
Error searching Kinogo: timeout
|
||||
```
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
### 1. Прокси не запускается
|
||||
|
||||
**Проблема:** `Proxy failed to start`
|
||||
|
||||
**Решение:**
|
||||
- Убедитесь, что InvisibleManXRay установлен
|
||||
- Проверьте путь в `src/shared/constants.ts`
|
||||
- Запустите вручную в терминале: `invisibleManXRay run`
|
||||
|
||||
### 2. Поиск не возвращает результаты
|
||||
|
||||
**Проблема:** `No results found`
|
||||
|
||||
**Решение:**
|
||||
- Проверьте доступность сайта в браузере
|
||||
- Откройте DevTools и проверьте ошибки в консоли
|
||||
- Убедитесь, что скрипт поиска корректен
|
||||
|
||||
### 3. Ошибка компиляции TypeScript
|
||||
|
||||
**Проблема:** `TS2345: Argument of type...`
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
# Очистить и переустановить зависимости
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Vite не запускается
|
||||
|
||||
**Проблема:** `Port 3000 is already in use`
|
||||
|
||||
**Решение:**
|
||||
- Остановите другие процессы на порту 3000
|
||||
- Или измените порт в `vite.config.ts`:
|
||||
```typescript
|
||||
server: {
|
||||
port: 3001,
|
||||
},
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
npm install
|
||||
|
||||
# Разработка
|
||||
npm run dev
|
||||
|
||||
# Сборка
|
||||
npm run build
|
||||
|
||||
# Создание установщика (Windows)
|
||||
npm run package:win
|
||||
|
||||
# Очистка
|
||||
rm -rf dist/ release/ node_modules/
|
||||
npm install
|
||||
```
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Прочитайте [README.md](./README.md) для полной документации
|
||||
2. Изучите [REQUIREMENTS.md](./REQUIREMENTS.md) для детальных требований
|
||||
3. Прочитайте [SEARCH_SCRIPTS.md](./SEARCH_SCRIPTS.md) для создания своих скриптов
|
||||
4. Проверьте примеры скриптов в `search-scripts/`
|
||||
|
||||
## Поддержка
|
||||
|
||||
Если возникли проблемы:
|
||||
|
||||
1. Проверьте логи в терминале и DevTools
|
||||
2. Прочитайте документацию
|
||||
3. Создайте Issue на GitHub с описанием проблемы
|
||||
|
||||
---
|
||||
|
||||
**Готово!** Теперь вы можете начать работу с Media Center 🎬
|
||||
327
README.md
Normal file
327
README.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Media Center v2
|
||||
|
||||
Electron-приложение для просмотра фильмов и сериалов с глобальным поиском по множеству сайтов.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Глобальный поиск** - поиск фильмов на всех подключенных сайтах одновременно
|
||||
- **Кастомные поисковые скрипты** - для каждого сайта свой скрипт поиска
|
||||
- **Закладки** - сохранение избранных фильмов и сериалов
|
||||
- **Управление вкладками** - просмотр и переключение между открытыми страницами
|
||||
- **Прокси поддержка** - автоматическое использование прокси для заблокированных сайтов
|
||||
- **Автообновления** - проверка и установка новых версий
|
||||
- **Расширения браузера** - AdBlock и FoxyProxy
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- **Electron 28** - кроссплатформенный фреймворк
|
||||
- **React 18** - UI библиотека
|
||||
- **TypeScript** - язык разработки
|
||||
- **Vite** - сборщик для frontend
|
||||
- **Axios** - HTTP клиент
|
||||
- **Cheerio** - HTML парсинг
|
||||
- **NeDB** - встроенная база данных
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 18+ (для разработки)
|
||||
- npm или yarn
|
||||
|
||||
### Установка зависимостей
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Разработка
|
||||
|
||||
Запуск в режиме разработки:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Это запустит:
|
||||
- Vite dev server для React (порт 3000)
|
||||
- TypeScript компиляцию main process
|
||||
- Electron приложение с hot reload
|
||||
|
||||
### Сборка
|
||||
|
||||
Сборка всего проекта:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Создание установщика для Windows:
|
||||
|
||||
```bash
|
||||
npm run package:win
|
||||
```
|
||||
|
||||
Создание установщика для macOS:
|
||||
|
||||
```bash
|
||||
npm run package:mac
|
||||
```
|
||||
|
||||
Создание установщика для Linux:
|
||||
|
||||
```bash
|
||||
npm run package:linux
|
||||
```
|
||||
|
||||
Готовый установщик будет в папке `release/`.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
media-center/
|
||||
├── src/
|
||||
│ ├── main/ # Главный процесс Electron
|
||||
│ │ ├── index.ts # Точка входа
|
||||
│ │ ├── proxy.ts # Управление прокси
|
||||
│ │ ├── config.ts # Управление конфигурацией
|
||||
│ │ ├── database.ts # База данных
|
||||
│ │ ├── tabs.ts # Управление вкладками
|
||||
│ │ ├── search.ts # Система поиска
|
||||
│ │ ├── updater.ts # Обновления
|
||||
│ │ └── preload.ts # Preload скрипт
|
||||
│ │
|
||||
│ ├── renderer/ # Renderer процесс (React)
|
||||
│ │ ├── App.tsx # Главный компонент
|
||||
│ │ ├── pages/ # Страницы
|
||||
│ │ ├── components/ # Компоненты
|
||||
│ │ └── styles/ # CSS стили
|
||||
│ │
|
||||
│ └── shared/ # Общий код
|
||||
│ ├── types.ts # TypeScript типы
|
||||
│ └── constants.ts # Константы
|
||||
│
|
||||
├── config/ # Конфигурации
|
||||
│ └── sites.json # Настройки сайтов
|
||||
│
|
||||
├── search-scripts/ # Поисковые скрипты
|
||||
│ ├── kinogo.js
|
||||
│ ├── rutube.js
|
||||
│ ├── hdrezka.js
|
||||
│ ├── SCRIPT_TEMPLATE.js
|
||||
│ └── README.md
|
||||
│
|
||||
├── REQUIREMENTS.md # Детальные требования
|
||||
├── SEARCH_SCRIPTS.md # Документация по скриптам
|
||||
└── README.md # Этот файл
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Главная страница
|
||||
|
||||
Отображает карточки с доступными сайтами. Клик по карточке открывает сайт в новой вкладке.
|
||||
|
||||
### Глобальный поиск
|
||||
|
||||
1. Введите название фильма в поисковую строку
|
||||
2. Нажмите "Найти"
|
||||
3. Просмотрите результаты со всех сайтов
|
||||
4. Кликните на результат для открытия страницы фильма
|
||||
|
||||
### Закладки
|
||||
|
||||
1. При просмотре фильма добавьте его в закладки
|
||||
2. Все закладки доступны на странице "Закладки"
|
||||
3. Фильтруйте закладки по сайтам
|
||||
4. Удаляйте ненужные закладки
|
||||
|
||||
### Активные вкладки
|
||||
|
||||
- Просмотр всех открытых вкладок
|
||||
- Переключение между вкладками
|
||||
- Закрытие вкладок
|
||||
|
||||
### Настройки
|
||||
|
||||
**Конфигурация:**
|
||||
- URL сервера для обновления конфигураций
|
||||
- Обновление списка сайтов и скриптов
|
||||
|
||||
**Прокси:**
|
||||
- Статус прокси (запущен/остановлен)
|
||||
- Автозапуск при старте приложения
|
||||
- Ручное управление прокси
|
||||
|
||||
**Интерфейс:**
|
||||
- Выбор темы (светлая/темная)
|
||||
- Выбор языка (русский/английский)
|
||||
|
||||
**Обновления:**
|
||||
- Проверка доступных обновлений
|
||||
- Автоматическая загрузка и установка
|
||||
|
||||
## Поисковые скрипты
|
||||
|
||||
Каждый сайт имеет свой JavaScript скрипт для поиска. Скрипты выполняются в контексте Electron без необходимости внешнего Node.js.
|
||||
|
||||
### Создание нового скрипта
|
||||
|
||||
1. Скопируйте `search-scripts/SCRIPT_TEMPLATE.js`
|
||||
2. Назовите файл по имени сайта (например, `mysite.js`)
|
||||
3. Реализуйте функцию `search()`:
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
// Ваш код поиска
|
||||
return [
|
||||
{
|
||||
name: "Название фильма",
|
||||
url: "https://site.com/movie/123",
|
||||
image: "https://site.com/poster.jpg", // опционально
|
||||
year: "2023", // опционально
|
||||
description: "Описание" // опционально
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
4. Добавьте сайт в `config/sites.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mysite",
|
||||
"name": "My Site",
|
||||
"url": "https://mysite.com",
|
||||
"logo": "https://mysite.com/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": false,
|
||||
"searchScript": "mysite.js"
|
||||
}
|
||||
```
|
||||
|
||||
Подробнее см. [SEARCH_SCRIPTS.md](./SEARCH_SCRIPTS.md)
|
||||
|
||||
## Прокси (InvisibleManXRay)
|
||||
|
||||
Для доступа к заблокированным сайтам используется InvisibleManXRay прокси.
|
||||
|
||||
### Установка
|
||||
|
||||
1. Скачайте [InvisibleManXRay CLI](https://github.com/...)
|
||||
2. Установите в системный PATH или укажите путь в `src/shared/constants.ts`
|
||||
3. Приложение запустит прокси автоматически (если включен автозапуск)
|
||||
|
||||
### Настройка
|
||||
|
||||
В `src/shared/constants.ts`:
|
||||
|
||||
```typescript
|
||||
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay'; // или полный путь
|
||||
export const DEFAULT_PROXY_PORT = 10808;
|
||||
```
|
||||
|
||||
## Серверная часть (Backend API)
|
||||
|
||||
Для полной функциональности нужен сервер с API:
|
||||
|
||||
### GET /api/config/sites
|
||||
|
||||
Возвращает конфигурацию сайтов:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2025-10-14T12:00:00Z",
|
||||
"sites": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/version/check
|
||||
|
||||
Проверка обновлений:
|
||||
|
||||
```json
|
||||
{
|
||||
"latestVersion": "1.1.0",
|
||||
"updateAvailable": true,
|
||||
"downloadUrl": "https://server.com/downloads/media-center-1.1.0.exe",
|
||||
"changelog": "- Новые функции...",
|
||||
"releaseDate": "2025-10-14",
|
||||
"mandatory": false
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/downloads/{version}/{platform}
|
||||
|
||||
Скачивание установщика.
|
||||
|
||||
Подробнее см. [REQUIREMENTS.md](./REQUIREMENTS.md)
|
||||
|
||||
## Разработка
|
||||
|
||||
### Добавление нового сайта
|
||||
|
||||
1. Создайте поисковый скрипт в `search-scripts/`
|
||||
2. Добавьте сайт в `config/sites.json`
|
||||
3. Протестируйте поиск
|
||||
|
||||
### Отладка
|
||||
|
||||
- Откройте DevTools: F12
|
||||
- Логи main process: `console.log` видны в терминале
|
||||
- Логи renderer process: видны в DevTools Console
|
||||
- Ошибки скриптов: проверьте консоль при выполнении поиска
|
||||
|
||||
### TypeScript
|
||||
|
||||
Все типы находятся в `src/shared/types.ts`. Для добавления новых типов:
|
||||
|
||||
```typescript
|
||||
export interface MyType {
|
||||
field: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Скрипты выполняются в изолированном контексте
|
||||
- Context Isolation включена
|
||||
- Node Integration отключена в BrowserView
|
||||
- Строгая Content Security Policy
|
||||
- Таймауты для всех операций
|
||||
|
||||
## Производительность
|
||||
|
||||
- Ленивая загрузка вкладок
|
||||
- Кэширование результатов поиска
|
||||
- Параллельные запросы при поиске
|
||||
- Оптимизация памяти
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
1. **Прокси не запускается**: Убедитесь, что InvisibleManXRay установлен
|
||||
2. **Поиск не работает**: Проверьте доступность сайтов
|
||||
3. **Обновления не скачиваются**: Проверьте URL сервера в настройках
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
||||
## Автор
|
||||
|
||||
George
|
||||
|
||||
## Поддержка
|
||||
|
||||
Для вопросов и предложений создайте Issue в GitHub.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] История просмотров
|
||||
- [ ] Рекомендации на основе закладок
|
||||
- [ ] Синхронизация между устройствами
|
||||
- [ ] Встроенный видеоплеер
|
||||
- [ ] Автоматическая загрузка субтитров
|
||||
- [ ] Торрент интеграция
|
||||
- [ ] Уведомления о новых сериях
|
||||
351
REQUIREMENTS.md
Normal file
351
REQUIREMENTS.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Media Center v2 - Детальное Описание и Требования
|
||||
|
||||
## Общее Описание
|
||||
Electron-приложение представляет собой специализированный медиацентр для просмотра фильмов и сериалов. Это кастомный браузер, оптимизированный для работы с различными онлайн-кинотеатрами и видеохостингами.
|
||||
|
||||
## Основные Функции
|
||||
|
||||
### 1. Главная Страница (Home)
|
||||
- **Назначение**: Отображение карточек с предзаготовленными сайтами для просмотра фильмов
|
||||
- **Функционал**:
|
||||
- Отображение карточек сайтов (название, логотип, краткое описание)
|
||||
- Клик по карточке открывает сайт в новой вкладке
|
||||
- Возможность добавления новых сайтов через конфигурацию
|
||||
- Индикация доступности сайта (онлайн/офлайн)
|
||||
|
||||
### 2. Закладки (Bookmarks)
|
||||
- **Назначение**: Сохранение избранных фильмов и сериалов
|
||||
- **Функционал**:
|
||||
- Карточки с сохраненными фильмами/сериалами
|
||||
- Информация: название, постер, сайт-источник, прямая ссылка
|
||||
- Возможность удаления закладок
|
||||
- Клик открывает сохраненную страницу
|
||||
- Группировка по сайтам (опционально)
|
||||
|
||||
### 3. Активные Вкладки (Active Tabs)
|
||||
- **Назначение**: Управление открытыми вкладками
|
||||
- **Функционал**:
|
||||
- Список всех открытых вкладок
|
||||
- Превью страницы (скриншот или favicon + title)
|
||||
- Переключение между вкладками
|
||||
- Закрытие вкладок
|
||||
- Возможность добавить текущую страницу в закладки
|
||||
|
||||
### 4. Глобальный Поиск
|
||||
- **Назначение**: Поиск фильмов на всех доступных сайтах одновременно
|
||||
- **Расположение**: Верхняя часть интерфейса (всегда доступен)
|
||||
- **Функционал**:
|
||||
- Поисковая строка с автодополнением
|
||||
- Параллельный поиск на всех сайтах из конфигурации
|
||||
- Агрегация результатов с разных сайтов
|
||||
- Отображение результатов с указанием источника
|
||||
- Фильтрация результатов по сайтам
|
||||
- Индикация прогресса поиска
|
||||
|
||||
### 5. Система Конфигураций Сайтов
|
||||
- **Назначение**: Управление настройками парсинга для каждого сайта
|
||||
- **Структура конфигурации**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"sites": [
|
||||
{
|
||||
"id": "site_unique_id",
|
||||
"name": "Название сайта",
|
||||
"url": "https://example.com",
|
||||
"logo": "url_или_base64",
|
||||
"enabled": true,
|
||||
"useProxy": true,
|
||||
"search": {
|
||||
"endpoint": "/search",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"query": "{searchQuery}"
|
||||
},
|
||||
"headers": {},
|
||||
"responseType": "json",
|
||||
"parsing": {
|
||||
"type": "json",
|
||||
"resultsPath": "data.results",
|
||||
"mapping": {
|
||||
"title": "title",
|
||||
"url": "link",
|
||||
"image": "poster",
|
||||
"year": "year"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Поддерживаемые типы парсинга**:
|
||||
- JSON (стандартный API ответ)
|
||||
- HTML (парсинг DOM через селекторы)
|
||||
- XML (парсинг XML структуры)
|
||||
|
||||
### 6. Настройки (Settings)
|
||||
- **Конфигурация сайтов**:
|
||||
- Просмотр текущей конфигурации
|
||||
- URL сервера конфигураций (редактируемый)
|
||||
- Кнопка "Обновить конфигурацию" - загрузка с сервера
|
||||
- Последняя дата обновления
|
||||
|
||||
- **Прокси настройки**:
|
||||
- Статус прокси (запущен/остановлен)
|
||||
- Включение/отключение автозапуска прокси
|
||||
- Настройки InvisibleManXRay CLI
|
||||
|
||||
- **Общие настройки**:
|
||||
- Тема оформления (светлая/темная)
|
||||
- Язык интерфейса
|
||||
- Папка для загрузок
|
||||
- Очистка кэша
|
||||
|
||||
### 7. Система Версионирования и Обновлений
|
||||
- **Проверка версий**:
|
||||
- Автоматическая проверка при запуске
|
||||
- Ручная проверка через меню
|
||||
- Сравнение текущей версии с серверной
|
||||
|
||||
- **Процесс обновления**:
|
||||
- Уведомление о доступности новой версии
|
||||
- Отображение changelog (что нового)
|
||||
- Кнопка "Обновить сейчас" / "Напомнить позже"
|
||||
- Скачивание нового установщика
|
||||
- Запуск установки с закрытием текущего приложения
|
||||
|
||||
### 8. Прокси Интеграция
|
||||
- **InvisibleManXRay CLI**:
|
||||
- Автозапуск при старте приложения
|
||||
- Проверка статуса прокси
|
||||
- Логирование работы прокси
|
||||
- Возможность перезапуска
|
||||
|
||||
- **FoxyProxy Integration**:
|
||||
- Автоматическое переключение прокси для сайтов
|
||||
- Правила на основе конфигурации (useProxy: true/false)
|
||||
- Прямое подключение для сайтов без прокси
|
||||
|
||||
### 9. Расширения Браузера
|
||||
- **AdBlock**:
|
||||
- Блокировка рекламы на всех сайтах
|
||||
- Настройка белого списка (если нужно)
|
||||
|
||||
- **FoxyProxy**:
|
||||
- Управление прокси правилами
|
||||
- Автоматическое переключение на основе URL
|
||||
|
||||
## Технический Стек
|
||||
|
||||
### Frontend
|
||||
- **Electron**: Основной фреймворк
|
||||
- **React**: UI библиотека
|
||||
- **TypeScript**: Язык разработки
|
||||
- **Electron BrowserView/WebView**: Для встраивания веб-страниц
|
||||
- **CSS/SCSS**: Стилизация
|
||||
- **Axios**: HTTP запросы
|
||||
- **Cheerio**: HTML парсинг (для поиска)
|
||||
|
||||
### Backend (Main Process)
|
||||
- **Node.js**: Серверная логика
|
||||
- **SQLite/NeDB**: Локальная БД для закладок и настроек
|
||||
- **Child Process**: Управление InvisibleManXRay CLI
|
||||
- **Electron Store**: Хранение настроек
|
||||
|
||||
### Расширения
|
||||
- **uBlock Origin / AdBlock Plus**: Блокировка рекламы
|
||||
- **FoxyProxy**: Управление прокси
|
||||
|
||||
## Архитектура Приложения
|
||||
|
||||
```
|
||||
media-center/
|
||||
├── src/
|
||||
│ ├── main/ # Main process
|
||||
│ │ ├── index.ts # Точка входа
|
||||
│ │ ├── proxy.ts # Управление прокси
|
||||
│ │ ├── config.ts # Управление конфигурацией
|
||||
│ │ ├── updater.ts # Система обновлений
|
||||
│ │ └── database.ts # Работа с БД
|
||||
│ │
|
||||
│ ├── renderer/ # Renderer process
|
||||
│ │ ├── App.tsx # Главный компонент
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Home.tsx
|
||||
│ │ │ ├── Bookmarks.tsx
|
||||
│ │ │ ├── ActiveTabs.tsx
|
||||
│ │ │ └── Settings.tsx
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── SearchBar.tsx
|
||||
│ │ │ ├── SiteCard.tsx
|
||||
│ │ │ ├── BookmarkCard.tsx
|
||||
│ │ │ ├── TabCard.tsx
|
||||
│ │ │ └── BrowserView.tsx
|
||||
│ │ └── services/
|
||||
│ │ ├── search.ts # Сервис поиска
|
||||
│ │ ├── config.ts # Работа с конфигурацией
|
||||
│ │ └── api.ts # API клиент
|
||||
│ │
|
||||
│ ├── shared/ # Общий код
|
||||
│ │ ├── types.ts # TypeScript типы
|
||||
│ │ └── constants.ts # Константы
|
||||
│ │
|
||||
│ └── extensions/ # Браузерные расширения
|
||||
│ ├── adblock/
|
||||
│ └── foxyproxy/
|
||||
│
|
||||
├── config/ # Конфигурационные файлы
|
||||
│ └── sites.json # Дефолтная конфигурация сайтов
|
||||
│
|
||||
├── package.json
|
||||
├── electron-builder.json # Настройки сборки
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Требования к Серверной Части (Backend API)
|
||||
|
||||
### 1. Конфигурация Сайтов
|
||||
**Endpoint**: `GET /api/config/sites`
|
||||
**Ответ**:
|
||||
```json
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"lastUpdated": "2025-10-14T12:00:00Z",
|
||||
"sites": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Проверка Версии
|
||||
**Endpoint**: `GET /api/version/check`
|
||||
**Параметры**: `?currentVersion=1.0.0`
|
||||
**Ответ**:
|
||||
```json
|
||||
{
|
||||
"latestVersion": "1.1.0",
|
||||
"updateAvailable": true,
|
||||
"downloadUrl": "https://server.com/downloads/media-center-1.1.0.exe",
|
||||
"changelog": "- Добавлен новый сайт\n- Исправлены ошибки поиска",
|
||||
"releaseDate": "2025-10-14",
|
||||
"mandatory": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Скачивание Обновления
|
||||
**Endpoint**: `GET /api/downloads/{version}/{platform}`
|
||||
**Ответ**: Бинарный файл установщика
|
||||
|
||||
### 4. Статистика (опционально)
|
||||
**Endpoint**: `POST /api/stats/usage`
|
||||
**Тело**:
|
||||
```json
|
||||
{
|
||||
"appVersion": "1.0.0",
|
||||
"event": "search",
|
||||
"site": "kinogo",
|
||||
"timestamp": "2025-10-14T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Основные Сценарии Использования
|
||||
|
||||
### Сценарий 1: Поиск фильма
|
||||
1. Пользователь вводит название в глобальный поиск
|
||||
2. Приложение отправляет запросы на все сайты параллельно
|
||||
3. Результаты агрегируются и отображаются
|
||||
4. Пользователь выбирает результат и открывается страница фильма
|
||||
5. Возможность добавить в закладки
|
||||
|
||||
### Сценарий 2: Работа с закладками
|
||||
1. Пользователь открывает страницу фильма
|
||||
2. Нажимает "Добавить в закладки"
|
||||
3. Закладка сохраняется с метаданными
|
||||
4. Закладка отображается на странице "Закладки"
|
||||
5. Быстрый доступ к сохраненному фильму
|
||||
|
||||
### Сценарий 3: Обновление конфигурации
|
||||
1. Пользователь открывает "Настройки"
|
||||
2. Нажимает "Обновить конфигурацию"
|
||||
3. Приложение загружает новую конфигурацию с сервера
|
||||
4. Применяет изменения (новые сайты, обновленные правила парсинга)
|
||||
5. Уведомляет пользователя об успехе
|
||||
|
||||
### Сценарий 4: Обновление приложения
|
||||
1. При запуске приложение проверяет версию
|
||||
2. Если доступно обновление - показывает уведомление
|
||||
3. Пользователь может обновить сейчас или позже
|
||||
4. При согласии скачивается новый установщик
|
||||
5. Приложение закрывается и запускается установка
|
||||
|
||||
## UI/UX Требования
|
||||
|
||||
### Дизайн
|
||||
- **Стиль**: Современный, минималистичный
|
||||
- **Цветовая схема**: Темная тема по умолчанию (с возможностью переключения)
|
||||
- **Шрифты**: Читабельные, sans-serif (например, Inter, Roboto)
|
||||
- **Иконки**: Использовать библиотеку (например, Lucide, Heroicons)
|
||||
|
||||
### Навигация
|
||||
- **Главное меню**: Боковая панель или верхнее меню с вкладками
|
||||
- **Поиск**: Всегда доступен в верхней части
|
||||
- **Быстрые действия**: Контекстное меню на карточках
|
||||
|
||||
### Адаптивность
|
||||
- Поддержка разных разрешений экрана
|
||||
- Минимальное разрешение: 1280x720
|
||||
- Оптимально: 1920x1080
|
||||
|
||||
## Безопасность
|
||||
|
||||
1. **Санитизация URL**: Проверка и очистка всех внешних URL
|
||||
2. **Content Security Policy**: Строгая CSP для renderer process
|
||||
3. **Node Integration**: Отключена в BrowserView
|
||||
4. **Context Isolation**: Включена
|
||||
5. **Безопасное хранение**: Конфиденциальные данные в зашифрованном виде
|
||||
|
||||
## Производительность
|
||||
|
||||
1. **Ленивая загрузка**: Вкладки загружаются только при активации
|
||||
2. **Кэширование**: Результаты поиска и конфигурации кэшируются
|
||||
3. **Оптимизация памяти**: Автоматическое закрытие неиспользуемых вкладок
|
||||
4. **Параллельные запросы**: Поиск выполняется параллельно
|
||||
|
||||
## Этапы Разработки
|
||||
|
||||
### Фаза 1: Базовая структура
|
||||
- [x] Настройка Electron проекта
|
||||
- [ ] Основная архитектура (main + renderer)
|
||||
- [ ] Базовый UI с навигацией
|
||||
|
||||
### Фаза 2: Основной функционал
|
||||
- [ ] Система конфигураций
|
||||
- [ ] Страница Home с карточками сайтов
|
||||
- [ ] BrowserView интеграция
|
||||
- [ ] Система закладок
|
||||
|
||||
### Фаза 3: Продвинутый функционал
|
||||
- [ ] Глобальный поиск
|
||||
- [ ] Парсинг для разных типов ответов
|
||||
- [ ] Управление активными вкладками
|
||||
|
||||
### Фаза 4: Прокси и расширения
|
||||
- [ ] Интеграция InvisibleManXRay
|
||||
- [ ] Установка AdBlock
|
||||
- [ ] Настройка FoxyProxy
|
||||
|
||||
### Фаза 5: Обновления и полировка
|
||||
- [ ] Система версионирования
|
||||
- [ ] Автообновление
|
||||
- [ ] Финальная оптимизация
|
||||
- [ ] Тестирование
|
||||
|
||||
## Дополнительные Возможности (Future Features)
|
||||
|
||||
1. **История просмотров**: Отслеживание просмотренного контента
|
||||
2. **Рекомендации**: На основе истории и закладок
|
||||
3. **Синхронизация**: Между устройствами (через облако)
|
||||
4. **Плеер интеграция**: Встроенный видеоплеер
|
||||
5. **Субтитры**: Автоматическая загрузка субтитров
|
||||
6. **Торрент интеграция**: Загрузка через торренты
|
||||
7. **Уведомления**: О новых сериях любимых сериалов
|
||||
271
SEARCH_SCRIPTS.md
Normal file
271
SEARCH_SCRIPTS.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Система Поисковых Скриптов
|
||||
|
||||
## Обзор
|
||||
|
||||
Media Center использует систему кастомных JavaScript скриптов для поиска контента на различных сайтах. Каждый сайт имеет свой собственный скрипт, который знает, как выполнять поиск на этом конкретном сайте.
|
||||
|
||||
## Преимущества подхода
|
||||
|
||||
1. **Гибкость**: Каждый сайт может иметь уникальную логику поиска
|
||||
2. **Расширяемость**: Легко добавлять новые сайты без изменения основного кода
|
||||
3. **Обновляемость**: Скрипты можно обновлять с сервера
|
||||
4. **Нет зависимости от Node.js**: Скрипты выполняются в контексте Electron
|
||||
|
||||
## Структура скрипта
|
||||
|
||||
Каждый скрипт - это JavaScript файл, который экспортирует функцию `search`:
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
// Ваша логика поиска
|
||||
return [
|
||||
{
|
||||
name: "Название фильма",
|
||||
url: "https://site.com/movie/123",
|
||||
image: "https://site.com/poster.jpg", // опционально
|
||||
year: "2023", // опционально
|
||||
description: "Описание фильма", // опционально
|
||||
rating: "8.5" // опционально
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры функции
|
||||
|
||||
- `query` (string) - поисковый запрос пользователя
|
||||
- `siteUrl` (string) - базовый URL сайта
|
||||
- `useProxy` (boolean) - флаг использования прокси
|
||||
- `axios` - HTTP клиент для запросов
|
||||
- `cheerio` - библиотека для парсинга HTML
|
||||
- `proxyConfig` - объект с настройками прокси `{host, port}`
|
||||
|
||||
### Возвращаемое значение
|
||||
|
||||
Массив объектов с результатами. Обязательные поля:
|
||||
- `name` - название фильма/сериала
|
||||
- `url` - ссылка на страницу
|
||||
|
||||
Опциональные поля:
|
||||
- `image` - URL постера
|
||||
- `year` - год выпуска
|
||||
- `description` - краткое описание
|
||||
- `rating` - рейтинг
|
||||
|
||||
## Доступные инструменты
|
||||
|
||||
### axios
|
||||
|
||||
HTTP клиент для выполнения запросов:
|
||||
|
||||
```javascript
|
||||
// GET запрос
|
||||
const response = await axios.get('https://site.com/search', {
|
||||
params: { q: query },
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0...'
|
||||
}
|
||||
});
|
||||
|
||||
// POST запрос
|
||||
const response = await axios.post('https://site.com/search', {
|
||||
query: query
|
||||
}, {
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// С прокси
|
||||
if (useProxy && proxyConfig) {
|
||||
const response = await axios.get(url, {
|
||||
proxy: {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### cheerio
|
||||
|
||||
jQuery-подобная библиотека для парсинга HTML:
|
||||
|
||||
```javascript
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Поиск элементов
|
||||
const title = $('.movie-title').text();
|
||||
const link = $('a.movie-link').attr('href');
|
||||
|
||||
// Итерация по элементам
|
||||
$('.movie-card').each((index, element) => {
|
||||
const $el = $(element);
|
||||
const name = $el.find('.title').text().trim();
|
||||
const url = $el.find('a').attr('href');
|
||||
});
|
||||
```
|
||||
|
||||
## Примеры реализаций
|
||||
|
||||
### Пример 1: HTML парсинг (Kinogo)
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
const config = {
|
||||
params: { do: 'search', subaction: 'search', story: query },
|
||||
timeout: 15000,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0...' }
|
||||
};
|
||||
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
|
||||
}
|
||||
|
||||
const response = await axios.get(`${siteUrl}/index.php`, config);
|
||||
const $ = cheerio.load(response.data);
|
||||
const results = [];
|
||||
|
||||
$('.shortstory').each((i, el) => {
|
||||
const $item = $(el);
|
||||
const name = $item.find('.title a').text().trim();
|
||||
const url = $item.find('.title a').attr('href');
|
||||
const image = $item.find('img').attr('src');
|
||||
|
||||
if (name && url) {
|
||||
results.push({ name, url: siteUrl + url, image });
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: JSON API (Rutube)
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
const config = {
|
||||
params: { query, limit: 20 },
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
const response = await axios.get(`${siteUrl}/api/search/video/`, config);
|
||||
const data = response.data;
|
||||
|
||||
if (!data.results) return [];
|
||||
|
||||
return data.results.map(item => ({
|
||||
name: item.title,
|
||||
url: item.video_url || `${siteUrl}/video/${item.id}`,
|
||||
image: item.thumbnail_url,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Хранение скриптов
|
||||
|
||||
Скрипты хранятся в двух местах:
|
||||
|
||||
1. **Встроенные скрипты**: `<app>/search-scripts/`
|
||||
- Поставляются с приложением
|
||||
- Только для чтения
|
||||
- Используются по умолчанию
|
||||
|
||||
2. **Пользовательские скрипты**: `<userData>/search-scripts/`
|
||||
- Загружаются с сервера
|
||||
- Можно обновлять
|
||||
- Имеют приоритет над встроенными
|
||||
|
||||
## Конфигурация сайта
|
||||
|
||||
В `config/sites.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "kinogo",
|
||||
"name": "Kinogo",
|
||||
"url": "https://kinogo.biz",
|
||||
"logo": "https://kinogo.biz/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": true,
|
||||
"searchScript": "kinogo.js"
|
||||
}
|
||||
```
|
||||
|
||||
## Создание нового скрипта
|
||||
|
||||
1. Скопируйте `SCRIPT_TEMPLATE.js`
|
||||
2. Назовите файл по имени сайта (например, `mysite.js`)
|
||||
3. Реализуйте функцию `search()`
|
||||
4. Протестируйте с разными запросами
|
||||
5. Добавьте сайт в конфигурацию
|
||||
|
||||
## Обновление скриптов с сервера
|
||||
|
||||
Пользователи могут обновлять скрипты через настройки приложения:
|
||||
|
||||
1. Настройки → Конфигурация
|
||||
2. Кнопка "Обновить конфигурацию"
|
||||
3. Скачиваются новые скрипты и конфигурация
|
||||
4. Применяются автоматически
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Скрипты выполняются в изолированном контексте
|
||||
- Таймаут выполнения: 30 секунд
|
||||
- Нет доступа к файловой системе
|
||||
- Нет доступа к другим модулям Node.js
|
||||
- Только axios и cheerio
|
||||
|
||||
## Отладка
|
||||
|
||||
Для отладки скриптов:
|
||||
|
||||
1. Откройте DevTools (F12 в приложении)
|
||||
2. Выполните поиск
|
||||
3. Проверьте консоль на наличие ошибок
|
||||
4. Логи включают:
|
||||
- Найденное количество результатов
|
||||
- Ошибки выполнения
|
||||
- Невалидные результаты
|
||||
|
||||
## Требования к серверу
|
||||
|
||||
Сервер должен предоставлять:
|
||||
|
||||
### GET /api/config/sites
|
||||
|
||||
Возвращает конфигурацию со списком сайтов и ссылками на скрипты:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2025-10-14T12:00:00Z",
|
||||
"sites": [
|
||||
{
|
||||
"id": "kinogo",
|
||||
"name": "Kinogo",
|
||||
"url": "https://kinogo.biz",
|
||||
"logo": "https://kinogo.biz/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": true,
|
||||
"searchScript": "kinogo.js",
|
||||
"scriptUrl": "https://server.com/scripts/kinogo.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /scripts/{scriptName}
|
||||
|
||||
Возвращает содержимое скрипта (JavaScript файл).
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Обработка ошибок**: Всегда оборачивайте код в try-catch
|
||||
2. **Таймауты**: Устанавливайте разумные таймауты для запросов
|
||||
3. **Валидация**: Проверяйте наличие обязательных полей
|
||||
4. **User-Agent**: Используйте реалистичный User-Agent
|
||||
5. **Относительные URL**: Преобразуйте в абсолютные
|
||||
6. **Пустые результаты**: Возвращайте `[]` при ошибке, а не `null`
|
||||
33
config/sites.json
Normal file
33
config/sites.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2025-10-14T12:00:00Z",
|
||||
"sites": [
|
||||
{
|
||||
"id": "kinogo",
|
||||
"name": "Kinogo",
|
||||
"url": "https://kinogo.biz",
|
||||
"logo": "https://kinogo.biz/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": true,
|
||||
"searchScript": "kinogo.js"
|
||||
},
|
||||
{
|
||||
"id": "rutube",
|
||||
"name": "Rutube",
|
||||
"url": "https://rutube.ru",
|
||||
"logo": "https://rutube.ru/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": false,
|
||||
"searchScript": "rutube.js"
|
||||
},
|
||||
{
|
||||
"id": "hdrezka",
|
||||
"name": "HDRezka",
|
||||
"url": "https://hdrezka.ag",
|
||||
"logo": "https://hdrezka.ag/favicon.ico",
|
||||
"enabled": true,
|
||||
"useProxy": true,
|
||||
"searchScript": "hdrezka.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
6748
package-lock.json
generated
Normal file
6748
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
package.json
Normal file
80
package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "media-center",
|
||||
"version": "1.0.1",
|
||||
"description": "Media Center - Browser for watching movies with global search and proxy support",
|
||||
"main": "dist/main/main/index.js",
|
||||
"author": "George",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
|
||||
"dev:main": "tsc -p tsconfig.main.json --watch",
|
||||
"dev:renderer": "vite",
|
||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"package": "npm run build && electron-builder",
|
||||
"package:win": "npm run build && electron-builder --win",
|
||||
"package:mac": "npm run build && electron-builder --mac",
|
||||
"package:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"electron-store": "^8.1.0",
|
||||
"nedb": "^1.8.0",
|
||||
"uuid": "^9.0.1",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nedb": "^1.8.16",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-electron": "^0.28.0",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.media.center",
|
||||
"productName": "Media Center",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "assets/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "assets/icon.icns"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"icon": "assets/icon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
135
search-scripts/README.md
Normal file
135
search-scripts/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Поисковые Скрипты для Media Center
|
||||
|
||||
Эта папка содержит кастомные JavaScript скрипты для поиска фильмов и сериалов на различных сайтах.
|
||||
|
||||
## Как работают скрипты
|
||||
|
||||
Каждый скрипт выполняется в защищенном контексте Electron и получает доступ к следующим инструментам:
|
||||
|
||||
- **axios** - для HTTP запросов
|
||||
- **cheerio** - для парсинга HTML (jQuery-подобный синтаксис)
|
||||
- **proxyConfig** - настройки прокси (если включен для сайта)
|
||||
|
||||
## Структура скрипта
|
||||
|
||||
Каждый скрипт должен экспортировать функцию `search` со следующей сигнатурой:
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
// Ваш код поиска
|
||||
return [{name: "Название фильма", url: "https://..."}];
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры:
|
||||
|
||||
- `query` (string) - поисковый запрос пользователя
|
||||
- `siteUrl` (string) - базовый URL сайта (например, "https://kinogo.biz")
|
||||
- `useProxy` (boolean) - нужно ли использовать прокси
|
||||
- `axios` (object) - экземпляр axios для HTTP запросов
|
||||
- `cheerio` (object) - библиотека для парсинга HTML
|
||||
- `proxyConfig` (object) - настройки прокси `{host: string, port: number}`
|
||||
|
||||
### Возвращаемое значение:
|
||||
|
||||
Массив объектов с результатами поиска. Каждый объект должен содержать:
|
||||
|
||||
**Обязательные поля:**
|
||||
- `name` (string) - название фильма/сериала
|
||||
- `url` (string) - ссылка на страницу фильма
|
||||
|
||||
**Опциональные поля:**
|
||||
- `image` (string) - URL постера
|
||||
- `year` (string) - год выпуска
|
||||
- `description` (string) - описание
|
||||
- `rating` (string) - рейтинг
|
||||
|
||||
## Примеры
|
||||
|
||||
### Пример 1: JSON API
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
const config = {
|
||||
params: { q: query },
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
|
||||
}
|
||||
|
||||
const response = await axios.get(`${siteUrl}/api/search`, config);
|
||||
|
||||
return response.data.results.map(item => ({
|
||||
name: item.title,
|
||||
url: item.link,
|
||||
image: item.poster
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: HTML парсинг
|
||||
|
||||
```javascript
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
const config = {
|
||||
params: { q: query },
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = { host: proxyConfig.host, port: proxyConfig.port };
|
||||
}
|
||||
|
||||
const response = await axios.get(`${siteUrl}/search`, config);
|
||||
const $ = cheerio.load(response.data);
|
||||
const results = [];
|
||||
|
||||
$('.movie-card').each((i, el) => {
|
||||
const $el = $(el);
|
||||
results.push({
|
||||
name: $el.find('.title').text().trim(),
|
||||
url: siteUrl + $el.find('a').attr('href'),
|
||||
image: $el.find('img').attr('src')
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
## Добавление нового скрипта
|
||||
|
||||
1. Создайте файл `sitename.js` в этой папке
|
||||
2. Используйте `SCRIPT_TEMPLATE.js` как основу
|
||||
3. Реализуйте функцию `search()`
|
||||
4. Обновите конфигурацию сайта в настройках приложения
|
||||
|
||||
## Обновление скриптов
|
||||
|
||||
Скрипты можно обновлять с сервера через настройки приложения. При обновлении конфигурации новые скрипты автоматически загружаются.
|
||||
|
||||
## Расположение скриптов
|
||||
|
||||
Скрипты хранятся в двух местах:
|
||||
|
||||
1. **Встроенные скрипты**: `<app>/search-scripts/` (только для чтения)
|
||||
2. **Пользовательские скрипты**: `<userData>/search-scripts/` (можно обновлять)
|
||||
|
||||
Пользовательские скрипты имеют приоритет над встроенными.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Скрипты выполняются в изолированном контексте
|
||||
- Таймаут выполнения: 30 секунд
|
||||
- Доступ только к axios и cheerio
|
||||
- Нет доступа к файловой системе или другим модулям Node.js
|
||||
|
||||
## Отладка
|
||||
|
||||
Ошибки скриптов логируются в консоль Electron DevTools. Для отладки:
|
||||
|
||||
1. Откройте DevTools (F12)
|
||||
2. Выполните поиск
|
||||
3. Проверьте консоль на наличие ошибок
|
||||
96
search-scripts/SCRIPT_TEMPLATE.js
Normal file
96
search-scripts/SCRIPT_TEMPLATE.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Search script template for creating custom site search scripts
|
||||
*
|
||||
* Copy this file and modify the search() function to work with your target site.
|
||||
*
|
||||
* Available tools:
|
||||
* - axios: For making HTTP requests
|
||||
* - cheerio: For parsing HTML (jQuery-like syntax)
|
||||
* - proxyConfig: Proxy settings if useProxy is true
|
||||
*
|
||||
* @param {string} query - Search query entered by user
|
||||
* @param {string} siteUrl - Base URL of the website (e.g., "https://example.com")
|
||||
* @param {boolean} useProxy - Whether to use proxy for this request
|
||||
* @param {object} axios - Axios HTTP client
|
||||
* @param {object} cheerio - Cheerio HTML parser
|
||||
* @param {object} proxyConfig - Proxy configuration {host: string, port: number}
|
||||
* @returns {Promise<Array>} Array of results in format: [{name: string, url: string, image?: string, year?: string, description?: string, rating?: string}]
|
||||
*/
|
||||
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
try {
|
||||
// 1. Build the search URL
|
||||
const searchUrl = `${siteUrl}/search?q=${encodeURIComponent(query)}`;
|
||||
|
||||
// 2. Configure the request
|
||||
const config = {
|
||||
timeout: 15000, // 15 seconds timeout
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Add proxy if needed
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Make the HTTP request
|
||||
const response = await axios.get(searchUrl, config);
|
||||
|
||||
// 5a. If the response is JSON:
|
||||
// const data = response.data;
|
||||
// const results = data.items.map(item => ({
|
||||
// name: item.title,
|
||||
// url: item.link,
|
||||
// image: item.thumbnail,
|
||||
// year: item.year,
|
||||
// description: item.synopsis
|
||||
// }));
|
||||
|
||||
// 5b. If the response is HTML:
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
const results = [];
|
||||
|
||||
// Parse HTML and extract movie data
|
||||
$('.movie-card').each((index, element) => {
|
||||
const $item = $(element);
|
||||
|
||||
const name = $item.find('.movie-title').text().trim();
|
||||
const url = $item.find('a').attr('href');
|
||||
const image = $item.find('img').attr('src');
|
||||
const year = $item.find('.year').text().trim();
|
||||
const description = $item.find('.description').text().trim();
|
||||
|
||||
// Only add if name and url exist
|
||||
if (name && url) {
|
||||
results.push({
|
||||
name,
|
||||
url: url.startsWith('http') ? url : siteUrl + url,
|
||||
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
|
||||
year: year || undefined,
|
||||
description: description || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Return the results
|
||||
return results;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error.message);
|
||||
return []; // Return empty array on error
|
||||
}
|
||||
}
|
||||
|
||||
// Important notes:
|
||||
// 1. The function MUST be named 'search'
|
||||
// 2. It MUST return a Promise that resolves to an array
|
||||
// 3. Each result MUST have 'name' and 'url' fields
|
||||
// 4. Other fields (image, year, description, rating) are optional
|
||||
// 5. Handle errors gracefully and return [] on failure
|
||||
// 6. Test with different queries to ensure reliability
|
||||
87
search-scripts/hdrezka.js
Normal file
87
search-scripts/hdrezka.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* HDRezka.ag search script
|
||||
*
|
||||
* This script searches for movies/series on hdrezka.ag
|
||||
*
|
||||
* @param {string} query - Search query from user
|
||||
* @param {string} siteUrl - Base URL of the site
|
||||
* @param {boolean} useProxy - Whether to use proxy for requests
|
||||
* @param {object} axios - Axios instance for HTTP requests
|
||||
* @param {object} cheerio - Cheerio instance for HTML parsing
|
||||
* @param {object} proxyConfig - Proxy configuration {host, port}
|
||||
* @returns {Promise<Array>} Array of search results [{name, url, image?, year?, description?}]
|
||||
*/
|
||||
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
try {
|
||||
const searchUrl = `${siteUrl}/search/`;
|
||||
|
||||
// Prepare request config
|
||||
const config = {
|
||||
params: {
|
||||
do: 'search',
|
||||
subaction: 'search',
|
||||
q: query
|
||||
},
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8'
|
||||
}
|
||||
};
|
||||
|
||||
// Add proxy if needed
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port
|
||||
};
|
||||
}
|
||||
|
||||
// Make request
|
||||
const response = await axios.get(searchUrl, config);
|
||||
const html = response.data;
|
||||
|
||||
// Parse HTML
|
||||
const $ = cheerio.load(html);
|
||||
const results = [];
|
||||
|
||||
// Find all movie/series cards
|
||||
$('.b-content__inline_item').each((index, element) => {
|
||||
const $item = $(element);
|
||||
|
||||
// Extract title and URL
|
||||
const linkEl = $item.find('.b-content__inline_item-link a').first();
|
||||
const name = linkEl.text().trim();
|
||||
const url = linkEl.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
// Extract image
|
||||
const imageEl = $item.find('.b-content__inline_item-cover img').first();
|
||||
const image = imageEl.attr('src') || imageEl.attr('data-src');
|
||||
|
||||
// Extract year
|
||||
const infoText = $item.find('.info').text();
|
||||
const yearMatch = infoText.match(/(\d{4})/);
|
||||
const year = yearMatch ? yearMatch[1] : '';
|
||||
|
||||
// Extract rating if available
|
||||
const ratingText = $item.find('.imdb').text().trim();
|
||||
|
||||
results.push({
|
||||
name,
|
||||
url: url.startsWith('http') ? url : siteUrl + url,
|
||||
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
|
||||
year,
|
||||
rating: ratingText || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('HDRezka search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
85
search-scripts/kinogo.js
Normal file
85
search-scripts/kinogo.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Kinogo.biz search script
|
||||
*
|
||||
* This script searches for movies/series on kinogo.biz
|
||||
*
|
||||
* @param {string} query - Search query from user
|
||||
* @param {string} siteUrl - Base URL of the site
|
||||
* @param {boolean} useProxy - Whether to use proxy for requests
|
||||
* @param {object} axios - Axios instance for HTTP requests
|
||||
* @param {object} cheerio - Cheerio instance for HTML parsing
|
||||
* @param {object} proxyConfig - Proxy configuration {host, port}
|
||||
* @returns {Promise<Array>} Array of search results [{name, url, image?, year?, description?}]
|
||||
*/
|
||||
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
try {
|
||||
const searchUrl = `${siteUrl}/index.php`;
|
||||
|
||||
// Prepare request config
|
||||
const config = {
|
||||
params: {
|
||||
do: 'search',
|
||||
subaction: 'search',
|
||||
story: query
|
||||
},
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
// Add proxy if needed
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port
|
||||
};
|
||||
}
|
||||
|
||||
// Make request
|
||||
const response = await axios.get(searchUrl, config);
|
||||
const html = response.data;
|
||||
|
||||
// Parse HTML
|
||||
const $ = cheerio.load(html);
|
||||
const results = [];
|
||||
|
||||
// Find all movie cards
|
||||
$('.shortstory').each((index, element) => {
|
||||
const $item = $(element);
|
||||
|
||||
// Extract title
|
||||
const titleEl = $item.find('.shortstory-title a, .title a, h2 a').first();
|
||||
const name = titleEl.text().trim();
|
||||
const url = titleEl.attr('href');
|
||||
|
||||
if (!name || !url) return;
|
||||
|
||||
// Extract image
|
||||
const imageEl = $item.find('.shortstory-img img, .poster img, img').first();
|
||||
const image = imageEl.attr('src') || imageEl.attr('data-src');
|
||||
|
||||
// Extract year
|
||||
const yearText = $item.find('.year, .shortstory-year').text().trim();
|
||||
const yearMatch = yearText.match(/(\d{4})/);
|
||||
const year = yearMatch ? yearMatch[1] : '';
|
||||
|
||||
// Extract description
|
||||
const description = $item.find('.shortstory-desc, .description').text().trim();
|
||||
|
||||
results.push({
|
||||
name,
|
||||
url: url.startsWith('http') ? url : siteUrl + url,
|
||||
image: image ? (image.startsWith('http') ? image : siteUrl + image) : undefined,
|
||||
year,
|
||||
description: description || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Kinogo search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
63
search-scripts/rutube.js
Normal file
63
search-scripts/rutube.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Rutube.ru search script
|
||||
*
|
||||
* This script searches for videos on rutube.ru using their API
|
||||
*
|
||||
* @param {string} query - Search query from user
|
||||
* @param {string} siteUrl - Base URL of the site
|
||||
* @param {boolean} useProxy - Whether to use proxy for requests
|
||||
* @param {object} axios - Axios instance for HTTP requests
|
||||
* @param {object} cheerio - Cheerio instance for HTML parsing (not used)
|
||||
* @param {object} proxyConfig - Proxy configuration {host, port}
|
||||
* @returns {Promise<Array>} Array of search results [{name, url, image?, description?}]
|
||||
*/
|
||||
|
||||
async function search(query, siteUrl, useProxy, axios, cheerio, proxyConfig) {
|
||||
try {
|
||||
const searchUrl = `${siteUrl}/api/search/video/`;
|
||||
|
||||
// Prepare request config
|
||||
const config = {
|
||||
params: {
|
||||
query: query,
|
||||
limit: 20
|
||||
},
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
// Add proxy if needed (Rutube usually doesn't require proxy in Russia)
|
||||
if (useProxy && proxyConfig) {
|
||||
config.proxy = {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port
|
||||
};
|
||||
}
|
||||
|
||||
// Make request
|
||||
const response = await axios.get(searchUrl, config);
|
||||
const data = response.data;
|
||||
|
||||
// Check if results exist
|
||||
if (!data.results || !Array.isArray(data.results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map results to our format
|
||||
const results = data.results.map(item => {
|
||||
return {
|
||||
name: item.title || '',
|
||||
url: item.video_url || (siteUrl + '/video/' + item.id),
|
||||
image: item.thumbnail_url || undefined,
|
||||
description: item.description || undefined
|
||||
};
|
||||
});
|
||||
|
||||
return results.filter(r => r.name && r.url);
|
||||
} catch (error) {
|
||||
console.error('Rutube search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
103
src/main/config.ts
Normal file
103
src/main/config.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import { ConfigData } from '../shared/types';
|
||||
import { DEFAULT_CONFIG_SERVER_URL } from '../shared/constants';
|
||||
|
||||
export class ConfigManager {
|
||||
private config: ConfigData | null = null;
|
||||
private configPath: string;
|
||||
private defaultConfigPath: string;
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
this.configPath = path.join(userDataPath, 'config.json');
|
||||
// In dev: dist/main/main -> ../../../config/sites.json
|
||||
// In production: resources/app.asar/dist/main/main -> ../../../config/sites.json
|
||||
this.defaultConfigPath = path.join(__dirname, '../../../config/sites.json');
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<ConfigData> {
|
||||
try {
|
||||
// Try to load from user data
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
const data = fs.readFileSync(this.configPath, 'utf-8');
|
||||
this.config = JSON.parse(data);
|
||||
console.log('Config loaded from user data');
|
||||
} else {
|
||||
// Load default config
|
||||
const data = fs.readFileSync(this.defaultConfigPath, 'utf-8');
|
||||
const defaultConfig = JSON.parse(data) as ConfigData;
|
||||
this.config = defaultConfig;
|
||||
// Save to user data
|
||||
await this.saveConfig(defaultConfig);
|
||||
console.log('Default config loaded and saved');
|
||||
}
|
||||
|
||||
return this.config as ConfigData;
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig(config: ConfigData): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
this.config = config;
|
||||
console.log('Config saved');
|
||||
} catch (error) {
|
||||
console.error('Error saving config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ConfigData> {
|
||||
if (!this.config) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.config!;
|
||||
}
|
||||
|
||||
async updateFromServer(serverUrl?: string): Promise<ConfigData> {
|
||||
try {
|
||||
const url = serverUrl || DEFAULT_CONFIG_SERVER_URL;
|
||||
const endpoint = `${url}/config/sites`;
|
||||
|
||||
console.log(`Fetching config from: ${endpoint}`);
|
||||
|
||||
const response = await axios.get<ConfigData>(endpoint, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const newConfig = response.data;
|
||||
|
||||
// Validate config structure
|
||||
if (!newConfig.version || !newConfig.sites || !Array.isArray(newConfig.sites)) {
|
||||
throw new Error('Invalid config structure received from server');
|
||||
}
|
||||
|
||||
// Save new config
|
||||
await this.saveConfig(newConfig);
|
||||
|
||||
console.log(`Config updated to version ${newConfig.version}`);
|
||||
|
||||
return newConfig;
|
||||
} catch (error: any) {
|
||||
console.error('Error updating config from server:', error.message);
|
||||
throw new Error(`Failed to update config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
getSiteById(siteId: string) {
|
||||
return this.config?.sites.find((site) => site.id === siteId);
|
||||
}
|
||||
|
||||
getEnabledSites() {
|
||||
return this.config?.sites.filter((site) => site.enabled) || [];
|
||||
}
|
||||
}
|
||||
147
src/main/database.ts
Normal file
147
src/main/database.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import Datastore from 'nedb';
|
||||
import * as path from 'path';
|
||||
import { Bookmark, AppSettings } from '../shared/types';
|
||||
import { DEFAULT_SETTINGS } from '../shared/constants';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class DatabaseManager {
|
||||
private bookmarksDb: Datastore<any>;
|
||||
private settingsDb: Datastore<any>;
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
this.bookmarksDb = new Datastore({
|
||||
filename: path.join(userDataPath, 'bookmarks.db'),
|
||||
autoload: false,
|
||||
});
|
||||
|
||||
this.settingsDb = new Datastore({
|
||||
filename: path.join(userDataPath, 'settings.db'),
|
||||
autoload: false,
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.bookmarksDb.loadDatabase((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsDb.loadDatabase((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
this.bookmarksDb.ensureIndex({ fieldName: 'id', unique: true });
|
||||
this.bookmarksDb.ensureIndex({ fieldName: 'siteId' });
|
||||
|
||||
console.log('Database initialized');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Bookmarks methods
|
||||
async getBookmarks(): Promise<Bookmark[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.bookmarksDb
|
||||
.find({})
|
||||
.sort({ createdAt: -1 })
|
||||
.exec((err, docs) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(docs as Bookmark[]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBookmark(bookmark: Omit<Bookmark, 'id' | 'createdAt'>): Promise<Bookmark> {
|
||||
const newBookmark: Bookmark = {
|
||||
...bookmark,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.bookmarksDb.insert(newBookmark, (err, doc) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(doc as Bookmark);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async removeBookmark(id: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.bookmarksDb.remove({ id }, {}, (err, numRemoved) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(numRemoved > 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBookmarksBySite(siteId: string): Promise<Bookmark[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.bookmarksDb
|
||||
.find({ siteId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec((err, docs) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(docs as Bookmark[]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Settings methods
|
||||
async getSettings(): Promise<AppSettings> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.settingsDb.findOne({ type: 'app-settings' }, (err, doc) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (doc) {
|
||||
resolve(doc as AppSettings);
|
||||
} else {
|
||||
// Return default settings if not found
|
||||
resolve(DEFAULT_SETTINGS);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async saveSettings(settings: AppSettings): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.settingsDb.update(
|
||||
{ type: 'app-settings' },
|
||||
{ ...settings, type: 'app-settings' },
|
||||
{ upsert: true },
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// NeDB doesn't require explicit closing, but we can compact databases
|
||||
this.bookmarksDb.persistence.compactDatafile();
|
||||
this.settingsDb.persistence.compactDatafile();
|
||||
}
|
||||
}
|
||||
222
src/main/index.ts
Normal file
222
src/main/index.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { app, BrowserWindow, ipcMain, session } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { ProxyManager } from './proxy';
|
||||
import { ConfigManager } from './config';
|
||||
import { DatabaseManager } from './database';
|
||||
import { UpdaterManager } from './updater';
|
||||
import { TabManager } from './tabs';
|
||||
import { IPC_CHANNELS } from '../shared/types';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let proxyManager: ProxyManager;
|
||||
let configManager: ConfigManager;
|
||||
let databaseManager: DatabaseManager;
|
||||
let updaterManager: UpdaterManager;
|
||||
let tabManager: TabManager;
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
async function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
minWidth: 1024,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
webSecurity: true,
|
||||
},
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#1a1a1a',
|
||||
});
|
||||
|
||||
if (isDevelopment) {
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../../dist/renderer/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
// Initialize managers
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
proxyManager = new ProxyManager();
|
||||
configManager = new ConfigManager(userDataPath);
|
||||
databaseManager = new DatabaseManager(userDataPath);
|
||||
updaterManager = new UpdaterManager(configManager);
|
||||
tabManager = new TabManager();
|
||||
|
||||
// Load initial configuration
|
||||
await configManager.loadConfig();
|
||||
await databaseManager.init();
|
||||
|
||||
// Setup browser extensions
|
||||
await setupExtensions();
|
||||
|
||||
// Auto-start proxy if enabled
|
||||
const settings = await databaseManager.getSettings();
|
||||
if (settings.proxyAutoStart) {
|
||||
await proxyManager.start();
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
setTimeout(() => {
|
||||
updaterManager.checkForUpdates().then((versionInfo) => {
|
||||
if (versionInfo.updateAvailable && mainWindow) {
|
||||
mainWindow.webContents.send('update-available', versionInfo);
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function setupExtensions() {
|
||||
try {
|
||||
// Load extensions from extensions folder
|
||||
const extensionsPath = path.join(__dirname, '../../extensions');
|
||||
|
||||
// Load uBlock Origin
|
||||
const uBlockPath = path.join(extensionsPath, 'ublock');
|
||||
if (require('fs').existsSync(uBlockPath)) {
|
||||
await session.defaultSession.loadExtension(uBlockPath);
|
||||
console.log('uBlock Origin loaded');
|
||||
}
|
||||
|
||||
// Load FoxyProxy
|
||||
const foxyProxyPath = path.join(extensionsPath, 'foxyproxy');
|
||||
if (require('fs').existsSync(foxyProxyPath)) {
|
||||
await session.defaultSession.loadExtension(foxyProxyPath);
|
||||
console.log('FoxyProxy loaded');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading extensions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setupIpcHandlers() {
|
||||
// Config handlers
|
||||
ipcMain.handle(IPC_CHANNELS.GET_CONFIG, async () => {
|
||||
return await configManager.getConfig();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_CONFIG, async () => {
|
||||
return await configManager.updateFromServer();
|
||||
});
|
||||
|
||||
// Search handlers
|
||||
ipcMain.handle(IPC_CHANNELS.SEARCH_ALL_SITES, async (_, query: string) => {
|
||||
const config = await configManager.getConfig();
|
||||
const { searchAllSites } = await import('./search');
|
||||
return await searchAllSites(query, config.sites, proxyManager);
|
||||
});
|
||||
|
||||
// Bookmark handlers
|
||||
ipcMain.handle(IPC_CHANNELS.GET_BOOKMARKS, async () => {
|
||||
return await databaseManager.getBookmarks();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_BOOKMARK, async (_, bookmark) => {
|
||||
return await databaseManager.addBookmark(bookmark);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_BOOKMARK, async (_, id: string) => {
|
||||
return await databaseManager.removeBookmark(id);
|
||||
});
|
||||
|
||||
// Tab handlers
|
||||
ipcMain.handle(IPC_CHANNELS.GET_TABS, async () => {
|
||||
return tabManager.getTabs();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CREATE_TAB, async (_, url: string) => {
|
||||
return tabManager.createTab(url, mainWindow!);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CLOSE_TAB, async (_, tabId: string) => {
|
||||
return tabManager.closeTab(tabId);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ACTIVATE_TAB, async (_, tabId: string) => {
|
||||
return tabManager.activateTab(tabId, mainWindow!);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.HIDE_ACTIVE_TAB, async () => {
|
||||
return tabManager.hideActiveTab(mainWindow!);
|
||||
});
|
||||
|
||||
// Proxy handlers
|
||||
ipcMain.handle(IPC_CHANNELS.GET_PROXY_STATUS, async () => {
|
||||
return proxyManager.getStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.START_PROXY, async () => {
|
||||
return await proxyManager.start();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.STOP_PROXY, async () => {
|
||||
return await proxyManager.stop();
|
||||
});
|
||||
|
||||
// Version handlers
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_VERSION, async () => {
|
||||
return await updaterManager.checkForUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DOWNLOAD_UPDATE, async (_, downloadUrl: string) => {
|
||||
return await updaterManager.downloadUpdate(downloadUrl);
|
||||
});
|
||||
|
||||
// Settings handlers
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, async () => {
|
||||
return await databaseManager.getSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SAVE_SETTINGS, async (_, settings) => {
|
||||
return await databaseManager.saveSettings(settings);
|
||||
});
|
||||
}
|
||||
|
||||
app.userAgentFallback = app.userAgentFallback
|
||||
.replace(/\sElectron\/\S+/, '')
|
||||
.replace(/\smedia[-_]center[^ ]*/gi, '');
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await createWindow();
|
||||
|
||||
try {
|
||||
await initializeApp();
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
}
|
||||
|
||||
setupIpcHandlers();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
// Cleanup
|
||||
if (proxyManager) {
|
||||
await proxyManager.stop();
|
||||
}
|
||||
if (databaseManager) {
|
||||
databaseManager.close();
|
||||
}
|
||||
});
|
||||
97
src/main/preload.ts
Normal file
97
src/main/preload.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// IPC Channels (copied to avoid import issues in preload)
|
||||
const IPC_CHANNELS = {
|
||||
GET_CONFIG: 'config:get',
|
||||
UPDATE_CONFIG: 'config:update',
|
||||
SEARCH_ALL_SITES: 'search:all',
|
||||
GET_BOOKMARKS: 'bookmarks:get',
|
||||
ADD_BOOKMARK: 'bookmarks:add',
|
||||
REMOVE_BOOKMARK: 'bookmarks:remove',
|
||||
GET_TABS: 'tabs:get',
|
||||
CREATE_TAB: 'tabs:create',
|
||||
CLOSE_TAB: 'tabs:close',
|
||||
ACTIVATE_TAB: 'tabs:activate',
|
||||
HIDE_ACTIVE_TAB: 'tabs:hide',
|
||||
GET_PROXY_STATUS: 'proxy:status',
|
||||
START_PROXY: 'proxy:start',
|
||||
STOP_PROXY: 'proxy:stop',
|
||||
CHECK_VERSION: 'version:check',
|
||||
DOWNLOAD_UPDATE: 'version:download',
|
||||
GET_SETTINGS: 'settings:get',
|
||||
SAVE_SETTINGS: 'settings:save',
|
||||
};
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CONFIG),
|
||||
updateConfig: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CONFIG),
|
||||
|
||||
// Search
|
||||
searchAllSites: (query: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SEARCH_ALL_SITES, query),
|
||||
|
||||
// Bookmarks
|
||||
getBookmarks: () => ipcRenderer.invoke(IPC_CHANNELS.GET_BOOKMARKS),
|
||||
addBookmark: (bookmark: any) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_BOOKMARK, bookmark),
|
||||
removeBookmark: (id: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.REMOVE_BOOKMARK, id),
|
||||
|
||||
// Tabs
|
||||
getTabs: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TABS),
|
||||
createTab: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.CREATE_TAB, url),
|
||||
closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.CLOSE_TAB, tabId),
|
||||
activateTab: (tabId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ACTIVATE_TAB, tabId),
|
||||
hideActiveTab: () => ipcRenderer.invoke(IPC_CHANNELS.HIDE_ACTIVE_TAB),
|
||||
|
||||
// Proxy
|
||||
getProxyStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_PROXY_STATUS),
|
||||
startProxy: () => ipcRenderer.invoke(IPC_CHANNELS.START_PROXY),
|
||||
stopProxy: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_PROXY),
|
||||
|
||||
// Version
|
||||
checkVersion: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_VERSION),
|
||||
downloadUpdate: (downloadUrl: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_UPDATE, downloadUrl),
|
||||
|
||||
// Settings
|
||||
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),
|
||||
saveSettings: (settings: any) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings),
|
||||
|
||||
// Event listeners
|
||||
onUpdateAvailable: (callback: (versionInfo: any) => void) => {
|
||||
ipcRenderer.on('update-available', (_, versionInfo) => callback(versionInfo));
|
||||
},
|
||||
});
|
||||
|
||||
// Type declaration for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getConfig: () => Promise<any>;
|
||||
updateConfig: () => Promise<any>;
|
||||
searchAllSites: (query: string) => Promise<any[]>;
|
||||
getBookmarks: () => Promise<any[]>;
|
||||
addBookmark: (bookmark: any) => Promise<any>;
|
||||
removeBookmark: (id: string) => Promise<boolean>;
|
||||
getTabs: () => Promise<any[]>;
|
||||
createTab: (url: string) => Promise<any>;
|
||||
closeTab: (tabId: string) => Promise<boolean>;
|
||||
activateTab: (tabId: string) => Promise<boolean>;
|
||||
hideActiveTab: () => Promise<boolean>;
|
||||
getProxyStatus: () => Promise<any>;
|
||||
startProxy: () => Promise<any>;
|
||||
stopProxy: () => Promise<any>;
|
||||
checkVersion: () => Promise<any>;
|
||||
downloadUpdate: (downloadUrl: string) => Promise<string>;
|
||||
getSettings: () => Promise<any>;
|
||||
saveSettings: (settings: any) => Promise<void>;
|
||||
onUpdateAvailable: (callback: (versionInfo: any) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
145
src/main/proxy.ts
Normal file
145
src/main/proxy.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ProxyStatus } from '../shared/types';
|
||||
import { DEFAULT_PROXY_PORT, INVISIBLE_MAN_CLI_PATH } from '../shared/constants';
|
||||
|
||||
export class ProxyManager {
|
||||
private proxyProcess: ChildProcess | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private port: number = DEFAULT_PROXY_PORT;
|
||||
|
||||
async start(): Promise<ProxyStatus> {
|
||||
if (this.isRunning) {
|
||||
return {
|
||||
isRunning: true,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Start InvisibleManXRay CLI
|
||||
this.proxyProcess = spawn(INVISIBLE_MAN_CLI_PATH, ['run'], {
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.proxyProcess.stdout?.on('data', (data) => {
|
||||
console.log(`[Proxy STDOUT]: ${data}`);
|
||||
// Check if proxy is ready
|
||||
if (data.toString().includes('started') || data.toString().includes('running')) {
|
||||
this.isRunning = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.proxyProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[Proxy STDERR]: ${data}`);
|
||||
});
|
||||
|
||||
this.proxyProcess.on('error', (error) => {
|
||||
console.error('[Proxy Error]:', error);
|
||||
this.isRunning = false;
|
||||
});
|
||||
|
||||
this.proxyProcess.on('exit', (code) => {
|
||||
console.log(`[Proxy] Process exited with code ${code}`);
|
||||
this.isRunning = false;
|
||||
this.proxyProcess = null;
|
||||
});
|
||||
|
||||
// Wait for proxy to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Verify proxy is running
|
||||
const status = await this.checkProxyHealth();
|
||||
|
||||
if (status) {
|
||||
this.isRunning = true;
|
||||
return {
|
||||
isRunning: true,
|
||||
port: this.port,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Proxy failed to start');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to start proxy:', error);
|
||||
return {
|
||||
isRunning: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<ProxyStatus> {
|
||||
if (!this.isRunning || !this.proxyProcess) {
|
||||
return {
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.proxyProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
await new Promise((resolve) => {
|
||||
if (this.proxyProcess) {
|
||||
this.proxyProcess.on('exit', resolve);
|
||||
setTimeout(resolve, 5000); // Timeout after 5 seconds
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
this.isRunning = false;
|
||||
this.proxyProcess = null;
|
||||
|
||||
return {
|
||||
isRunning: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Failed to stop proxy:', error);
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): ProxyStatus {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
port: this.isRunning ? this.port : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkProxyHealth(): Promise<boolean> {
|
||||
try {
|
||||
// Try to connect to proxy port to verify it's running
|
||||
const axios = require('axios');
|
||||
|
||||
const response = await axios.get('http://www.google.com', {
|
||||
proxy: {
|
||||
host: '127.0.0.1',
|
||||
port: this.port,
|
||||
},
|
||||
timeout: 5000,
|
||||
}).catch(() => false);
|
||||
|
||||
return !!response;
|
||||
} catch (error) {
|
||||
// If connection test fails, just return true if process is alive
|
||||
return this.proxyProcess !== null && !this.proxyProcess.killed;
|
||||
}
|
||||
}
|
||||
|
||||
getProxyConfig() {
|
||||
if (!this.isRunning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: '127.0.0.1',
|
||||
port: this.port,
|
||||
protocol: 'http',
|
||||
};
|
||||
}
|
||||
}
|
||||
214
src/main/search.ts
Normal file
214
src/main/search.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { SiteConfig, SearchResult, SearchResultWithSource } from '../shared/types';
|
||||
import { ProxyManager } from './proxy';
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* Search context object that will be passed to custom search scripts
|
||||
*/
|
||||
interface SearchContext {
|
||||
query: string;
|
||||
siteUrl: string;
|
||||
useProxy: boolean;
|
||||
axios: typeof axios;
|
||||
cheerio: typeof cheerio;
|
||||
proxyConfig?: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all enabled sites using their custom scripts
|
||||
*/
|
||||
export async function searchAllSites(
|
||||
query: string,
|
||||
sites: SiteConfig[],
|
||||
proxyManager: ProxyManager
|
||||
): Promise<SearchResultWithSource[]> {
|
||||
const enabledSites = sites.filter((site) => site.enabled);
|
||||
|
||||
const searchPromises = enabledSites.map((site) =>
|
||||
searchSite(query, site, proxyManager)
|
||||
.then((results) => {
|
||||
// Add source information to each result
|
||||
return results.map((result) => ({
|
||||
...result,
|
||||
source: site.id,
|
||||
sourceName: site.name,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error searching ${site.name}:`, error.message);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(searchPromises);
|
||||
|
||||
// Flatten and return all results
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a single site using its custom script
|
||||
*/
|
||||
export async function searchSite(
|
||||
query: string,
|
||||
site: SiteConfig,
|
||||
proxyManager: ProxyManager
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
// Load the custom search script
|
||||
const scriptPath = getScriptPath(site.searchScript);
|
||||
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
console.error(`Search script not found: ${scriptPath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const scriptContent = fs.readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
// Prepare search context
|
||||
const context: SearchContext = {
|
||||
query,
|
||||
siteUrl: site.url,
|
||||
useProxy: site.useProxy,
|
||||
axios,
|
||||
cheerio,
|
||||
};
|
||||
|
||||
// Add proxy config if needed
|
||||
if (site.useProxy) {
|
||||
const proxyConfig = proxyManager.getProxyConfig();
|
||||
if (proxyConfig) {
|
||||
context.proxyConfig = {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the custom script in a safe context
|
||||
const results = await executeSearchScript(scriptContent, context);
|
||||
|
||||
// Validate results
|
||||
if (!Array.isArray(results)) {
|
||||
console.error(`Invalid results from ${site.name}: expected array`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Validate each result has required fields
|
||||
const validResults = results.filter((result) => {
|
||||
if (!result.name || !result.url) {
|
||||
console.warn(`Invalid result from ${site.name}:`, result);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Found ${validResults.length} results from ${site.name}`);
|
||||
|
||||
return validResults;
|
||||
} catch (error: any) {
|
||||
console.error(`Error executing search script for ${site.name}:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute custom search script in a controlled environment
|
||||
*/
|
||||
async function executeSearchScript(
|
||||
scriptContent: string,
|
||||
context: SearchContext
|
||||
): Promise<SearchResult[]> {
|
||||
// Create a safe execution context with required libraries
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
|
||||
// Create the search function from script content
|
||||
// The script should define a function that returns a Promise<SearchResult[]>
|
||||
const searchFunction = new AsyncFunction(
|
||||
'query',
|
||||
'siteUrl',
|
||||
'useProxy',
|
||||
'axios',
|
||||
'cheerio',
|
||||
'proxyConfig',
|
||||
`
|
||||
${scriptContent}
|
||||
|
||||
// Call the search function defined in the script
|
||||
if (typeof search === 'function') {
|
||||
return await search(query, siteUrl, useProxy, axios, cheerio, proxyConfig);
|
||||
} else {
|
||||
throw new Error('Search function not defined in script');
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// Execute the function with timeout
|
||||
const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Search timeout')), 30000)
|
||||
);
|
||||
|
||||
const searchPromise = searchFunction(
|
||||
context.query,
|
||||
context.siteUrl,
|
||||
context.useProxy,
|
||||
context.axios,
|
||||
context.cheerio,
|
||||
context.proxyConfig
|
||||
);
|
||||
|
||||
return Promise.race([searchPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute path to search script
|
||||
*/
|
||||
function getScriptPath(scriptName: string): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const scriptsDir = path.join(userDataPath, 'search-scripts');
|
||||
|
||||
// Check in user data directory first
|
||||
let scriptPath = path.join(scriptsDir, scriptName);
|
||||
if (fs.existsSync(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
// Fallback to default scripts in app directory
|
||||
scriptPath = path.join(__dirname, '../../search-scripts', scriptName);
|
||||
if (fs.existsSync(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
return scriptPath; // Return anyway, will be checked by caller
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available search scripts
|
||||
*/
|
||||
export function listAvailableScripts(): string[] {
|
||||
const scripts: string[] = [];
|
||||
|
||||
// Check default scripts
|
||||
const defaultScriptsDir = path.join(__dirname, '../../search-scripts');
|
||||
if (fs.existsSync(defaultScriptsDir)) {
|
||||
const files = fs.readdirSync(defaultScriptsDir);
|
||||
scripts.push(...files.filter((f) => f.endsWith('.js')));
|
||||
}
|
||||
|
||||
// Check user scripts
|
||||
const userDataPath = app.getPath('userData');
|
||||
const userScriptsDir = path.join(userDataPath, 'search-scripts');
|
||||
if (fs.existsSync(userScriptsDir)) {
|
||||
const files = fs.readdirSync(userScriptsDir);
|
||||
scripts.push(...files.filter((f) => f.endsWith('.js') && !scripts.includes(f)));
|
||||
}
|
||||
|
||||
return scripts;
|
||||
}
|
||||
151
src/main/tabs.ts
Normal file
151
src/main/tabs.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { BrowserView, BrowserWindow } from 'electron';
|
||||
import { ActiveTab } from '../shared/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class TabManager {
|
||||
private tabs: Map<string, { tab: ActiveTab; view: BrowserView }> = new Map();
|
||||
private activeTabId: string | null = null;
|
||||
|
||||
createTab(url: string, mainWindow: BrowserWindow): ActiveTab {
|
||||
const tabId = uuidv4();
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
|
||||
const tab: ActiveTab = {
|
||||
id: tabId,
|
||||
title: 'Loading...',
|
||||
url,
|
||||
isActive: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Load URL
|
||||
view.webContents.loadURL(url);
|
||||
|
||||
// Update title when page loads
|
||||
view.webContents.on('page-title-updated', (_, title) => {
|
||||
tab.title = title;
|
||||
});
|
||||
|
||||
// Update favicon
|
||||
view.webContents.on('page-favicon-updated', (_, favicons) => {
|
||||
if (favicons.length > 0) {
|
||||
tab.favicon = favicons[0];
|
||||
}
|
||||
});
|
||||
|
||||
this.tabs.set(tabId, { tab, view });
|
||||
|
||||
// If this is the first tab, activate it
|
||||
if (this.tabs.size === 1) {
|
||||
this.activateTab(tabId, mainWindow);
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
activateTab(tabId: string, mainWindow: BrowserWindow): boolean {
|
||||
const tabData = this.tabs.get(tabId);
|
||||
if (!tabData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deactivate previous tab
|
||||
if (this.activeTabId) {
|
||||
const prevTab = this.tabs.get(this.activeTabId);
|
||||
if (prevTab) {
|
||||
prevTab.tab.isActive = false;
|
||||
mainWindow.removeBrowserView(prevTab.view);
|
||||
}
|
||||
}
|
||||
|
||||
// Activate new tab
|
||||
tabData.tab.isActive = true;
|
||||
this.activeTabId = tabId;
|
||||
|
||||
mainWindow.addBrowserView(tabData.view);
|
||||
|
||||
const bounds = mainWindow.getContentBounds();
|
||||
// Reserve space for top navigation (e.g., 60px)
|
||||
tabData.view.setBounds({
|
||||
x: 0,
|
||||
y: 60,
|
||||
width: bounds.width,
|
||||
height: bounds.height - 60,
|
||||
});
|
||||
|
||||
// Auto-resize
|
||||
mainWindow.on('resize', () => {
|
||||
const newBounds = mainWindow.getContentBounds();
|
||||
tabData.view.setBounds({
|
||||
x: 0,
|
||||
y: 60,
|
||||
width: newBounds.width,
|
||||
height: newBounds.height - 60,
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
hideActiveTab(mainWindow: BrowserWindow): boolean {
|
||||
if (!this.activeTabId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tabData = this.tabs.get(this.activeTabId);
|
||||
if (!tabData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide the active tab by removing the BrowserView
|
||||
tabData.tab.isActive = false;
|
||||
mainWindow.removeBrowserView(tabData.view);
|
||||
this.activeTabId = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
closeTab(tabId: string): boolean {
|
||||
const tabData = this.tabs.get(tabId);
|
||||
if (!tabData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Destroy the view
|
||||
(tabData.view.webContents as any).destroy();
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// If this was the active tab, activate another one
|
||||
if (this.activeTabId === tabId) {
|
||||
this.activeTabId = null;
|
||||
const nextTab = Array.from(this.tabs.keys())[0];
|
||||
if (nextTab) {
|
||||
// Will be activated when user selects it
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getTabs(): ActiveTab[] {
|
||||
return Array.from(this.tabs.values()).map((data) => data.tab);
|
||||
}
|
||||
|
||||
getActiveTab(): ActiveTab | null {
|
||||
if (!this.activeTabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabData = this.tabs.get(this.activeTabId);
|
||||
return tabData ? tabData.tab : null;
|
||||
}
|
||||
}
|
||||
99
src/main/updater.ts
Normal file
99
src/main/updater.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app, shell } from 'electron';
|
||||
import { VersionInfo } from '../shared/types';
|
||||
import { APP_VERSION } from '../shared/constants';
|
||||
import { ConfigManager } from './config';
|
||||
|
||||
export class UpdaterManager {
|
||||
private configManager: ConfigManager;
|
||||
|
||||
constructor(configManager: ConfigManager) {
|
||||
this.configManager = configManager;
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<VersionInfo> {
|
||||
try {
|
||||
const settings = await this.configManager.getConfig();
|
||||
const serverUrl = (settings as any).serverUrl || 'https://your-server.com/api';
|
||||
const endpoint = `${serverUrl}/version/check`;
|
||||
|
||||
const response = await axios.get<VersionInfo>(endpoint, {
|
||||
params: {
|
||||
currentVersion: APP_VERSION,
|
||||
platform: process.platform,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking for updates:', error.message);
|
||||
// Return no update available if check fails
|
||||
return {
|
||||
latestVersion: APP_VERSION,
|
||||
updateAvailable: false,
|
||||
downloadUrl: '',
|
||||
changelog: '',
|
||||
releaseDate: '',
|
||||
mandatory: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadUpdate(downloadUrl: string): Promise<string> {
|
||||
try {
|
||||
const downloadsPath = app.getPath('downloads');
|
||||
const fileName = path.basename(new URL(downloadUrl).pathname);
|
||||
const filePath = path.join(downloadsPath, fileName);
|
||||
|
||||
const response = await axios.get(downloadUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 300000, // 5 minutes
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => resolve(filePath));
|
||||
writer.on('error', reject);
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error downloading update:', error.message);
|
||||
throw new Error(`Failed to download update: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async installUpdate(installerPath: string): Promise<void> {
|
||||
try {
|
||||
// Open the installer
|
||||
await shell.openPath(installerPath);
|
||||
|
||||
// Close the app after a short delay
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
console.error('Error installing update:', error.message);
|
||||
throw new Error(`Failed to install update: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
42
src/renderer/App.tsx
Normal file
42
src/renderer/App.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
import Bookmarks from './pages/Bookmarks';
|
||||
import ActiveTabs from './pages/ActiveTabs';
|
||||
import Settings from './pages/Settings';
|
||||
import UpdateNotification from './components/UpdateNotification';
|
||||
import { VersionInfo } from '../shared/types';
|
||||
|
||||
function App() {
|
||||
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for update notifications
|
||||
window.electronAPI.onUpdateAvailable((versionInfo) => {
|
||||
setUpdateInfo(versionInfo);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
{updateInfo && updateInfo.updateAvailable && (
|
||||
<UpdateNotification
|
||||
versionInfo={updateInfo}
|
||||
onClose={() => setUpdateInfo(null)}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/bookmarks" element={<Bookmarks />} />
|
||||
<Route path="/tabs" element={<ActiveTabs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
52
src/renderer/components/BookmarkCard.tsx
Normal file
52
src/renderer/components/BookmarkCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Bookmark } from '../../shared/types';
|
||||
import '../styles/BookmarkCard.css';
|
||||
|
||||
interface BookmarkCardProps {
|
||||
bookmark: Bookmark;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const BookmarkCard: React.FC<BookmarkCardProps> = ({
|
||||
bookmark,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}) => {
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`Удалить закладку "${bookmark.title}"?`)) {
|
||||
onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bookmark-card" onClick={onOpen}>
|
||||
{bookmark.image && (
|
||||
<img
|
||||
src={bookmark.image}
|
||||
alt={bookmark.title}
|
||||
className="bookmark-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="bookmark-info">
|
||||
<h3 className="bookmark-title">{bookmark.title}</h3>
|
||||
<p className="bookmark-site">{bookmark.siteName}</p>
|
||||
{bookmark.metadata?.year && (
|
||||
<p className="bookmark-year">{bookmark.metadata.year}</p>
|
||||
)}
|
||||
{bookmark.metadata?.description && (
|
||||
<p className="bookmark-description">{bookmark.metadata.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="delete-button" onClick={handleDelete}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkCard;
|
||||
64
src/renderer/components/Layout.tsx
Normal file
64
src/renderer/components/Layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import SearchBar from './SearchBar';
|
||||
import '../styles/Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const handleBackClick = async () => {
|
||||
await window.electronAPI.hideActiveTab();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="header">
|
||||
<div className="header-top">
|
||||
<button className="back-button" onClick={handleBackClick} title="Закрыть вкладку">
|
||||
← Назад
|
||||
</button>
|
||||
<h1 className="app-title">Media Center</h1>
|
||||
<div className="search-container">
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav-tabs">
|
||||
<button
|
||||
className={`nav-tab ${isActive('/') ? 'active' : ''}`}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Главная
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${isActive('/bookmarks') ? 'active' : ''}`}
|
||||
onClick={() => navigate('/bookmarks')}
|
||||
>
|
||||
Закладки
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${isActive('/tabs') ? 'active' : ''}`}
|
||||
onClick={() => navigate('/tabs')}
|
||||
>
|
||||
Активные вкладки
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${isActive('/settings') ? 'active' : ''}`}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
Настройки
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<main className="main-content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
74
src/renderer/components/SearchBar.tsx
Normal file
74
src/renderer/components/SearchBar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import SearchResults from './SearchResults';
|
||||
import { SearchResultWithSource } from '../../shared/types';
|
||||
import '../styles/SearchBar.css';
|
||||
|
||||
const SearchBar: React.FC = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResultWithSource[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!query.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setShowResults(true);
|
||||
|
||||
try {
|
||||
const searchResults = await window.electronAPI.searchAllSites(query);
|
||||
setResults(searchResults);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultClick = async (result: SearchResultWithSource) => {
|
||||
try {
|
||||
await window.electronAPI.createTab(result.url);
|
||||
setShowResults(false);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
} catch (error) {
|
||||
console.error('Error opening tab:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search-bar">
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск фильмов на всех сайтах..."
|
||||
className="search-input"
|
||||
disabled={isSearching}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="search-button"
|
||||
disabled={isSearching || !query.trim()}
|
||||
>
|
||||
{isSearching ? 'Поиск...' : 'Найти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{showResults && (
|
||||
<SearchResults
|
||||
results={results}
|
||||
isSearching={isSearching}
|
||||
onResultClick={handleResultClick}
|
||||
onClose={() => setShowResults(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
80
src/renderer/components/SearchResults.tsx
Normal file
80
src/renderer/components/SearchResults.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { SearchResultWithSource } from '../../shared/types';
|
||||
import '../styles/SearchResults.css';
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResultWithSource[];
|
||||
isSearching: boolean;
|
||||
onResultClick: (result: SearchResultWithSource) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
results,
|
||||
isSearching,
|
||||
onResultClick,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<div className="search-results-overlay" onClick={onClose}>
|
||||
<div className="search-results" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="search-results-header">
|
||||
<h3>Результаты поиска</h3>
|
||||
<button className="close-button" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="search-results-content">
|
||||
{isSearching && (
|
||||
<div className="search-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Поиск на всех сайтах...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && results.length === 0 && (
|
||||
<div className="no-results">
|
||||
<p>Ничего не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="results-grid">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={`${result.source}-${index}`}
|
||||
className="result-card"
|
||||
onClick={() => onResultClick(result)}
|
||||
>
|
||||
{result.image && (
|
||||
<img
|
||||
src={result.image}
|
||||
alt={result.name}
|
||||
className="result-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="result-info">
|
||||
<h4 className="result-title">{result.name}</h4>
|
||||
<p className="result-source">{result.sourceName}</p>
|
||||
{result.year && (
|
||||
<p className="result-year">{result.year}</p>
|
||||
)}
|
||||
{result.description && (
|
||||
<p className="result-description">{result.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
35
src/renderer/components/SiteCard.tsx
Normal file
35
src/renderer/components/SiteCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { SiteConfig } from '../../shared/types';
|
||||
import '../styles/SiteCard.css';
|
||||
|
||||
interface SiteCardProps {
|
||||
site: SiteConfig;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const SiteCard: React.FC<SiteCardProps> = ({ site, onClick }) => {
|
||||
return (
|
||||
<div className="site-card" onClick={onClick}>
|
||||
<div className="site-card-header">
|
||||
{site.logo && (
|
||||
<img
|
||||
src={site.logo}
|
||||
alt={site.name}
|
||||
className="site-logo"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="site-name">{site.name}</h3>
|
||||
</div>
|
||||
<p className="site-url">{site.url}</p>
|
||||
<div className="site-badges">
|
||||
{site.useProxy && <span className="badge proxy-badge">Прокси</span>}
|
||||
<span className="badge enabled-badge">Активен</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteCard;
|
||||
40
src/renderer/components/TabCard.tsx
Normal file
40
src/renderer/components/TabCard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { ActiveTab } from '../../shared/types';
|
||||
import '../styles/TabCard.css';
|
||||
|
||||
interface TabCardProps {
|
||||
tab: ActiveTab;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TabCard: React.FC<TabCardProps> = ({ tab, onActivate, onClose }) => {
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tab-card ${tab.isActive ? 'active' : ''}`}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className="tab-favicon">
|
||||
{tab.favicon ? (
|
||||
<img src={tab.favicon} alt="" />
|
||||
) : (
|
||||
<div className="favicon-placeholder">🌐</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tab-info">
|
||||
<h3 className="tab-title">{tab.title || 'Loading...'}</h3>
|
||||
<p className="tab-url">{tab.url}</p>
|
||||
</div>
|
||||
<button className="tab-close-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabCard;
|
||||
60
src/renderer/components/UpdateNotification.tsx
Normal file
60
src/renderer/components/UpdateNotification.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { VersionInfo } from '../../shared/types';
|
||||
import '../styles/UpdateNotification.css';
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
versionInfo: VersionInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||
versionInfo,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsDownloading(true);
|
||||
|
||||
try {
|
||||
const installerPath = await window.electronAPI.downloadUpdate(
|
||||
versionInfo.downloadUrl
|
||||
);
|
||||
alert(`Обновление загружено: ${installerPath}\nПриложение будет закрыто для установки.`);
|
||||
// App will close automatically after opening installer
|
||||
} catch (error) {
|
||||
console.error('Error downloading update:', error);
|
||||
alert('Ошибка при загрузке обновления');
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="update-notification">
|
||||
<div className="update-content">
|
||||
<h3>Доступно обновление!</h3>
|
||||
<p className="version">
|
||||
Версия <strong>{versionInfo.latestVersion}</strong>
|
||||
</p>
|
||||
<div className="changelog">
|
||||
<h4>Что нового:</h4>
|
||||
<pre>{versionInfo.changelog}</pre>
|
||||
</div>
|
||||
<div className="update-actions">
|
||||
<button
|
||||
className="button-primary"
|
||||
onClick={handleUpdate}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? 'Загрузка...' : 'Обновить сейчас'}
|
||||
</button>
|
||||
<button className="button-secondary" onClick={onClose}>
|
||||
Напомнить позже
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateNotification;
|
||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Media Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
src/renderer/main.tsx
Normal file
10
src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
84
src/renderer/pages/ActiveTabs.tsx
Normal file
84
src/renderer/pages/ActiveTabs.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActiveTab } from '../../shared/types';
|
||||
import TabCard from '../components/TabCard';
|
||||
import '../styles/ActiveTabs.css';
|
||||
|
||||
const ActiveTabs: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<ActiveTab[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTabs();
|
||||
// Refresh tabs every 2 seconds
|
||||
const interval = setInterval(loadTabs, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadTabs = async () => {
|
||||
try {
|
||||
const data = await window.electronAPI.getTabs();
|
||||
setTabs(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading tabs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (tabId: string) => {
|
||||
try {
|
||||
await window.electronAPI.activateTab(tabId);
|
||||
loadTabs(); // Refresh to show active state
|
||||
} catch (error) {
|
||||
console.error('Error activating tab:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (tabId: string) => {
|
||||
try {
|
||||
const success = await window.electronAPI.closeTab(tabId);
|
||||
if (success) {
|
||||
setTabs(tabs.filter((t) => t.id !== tabId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error closing tab:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Загрузка вкладок...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabs-page">
|
||||
<h2>Активные вкладки</h2>
|
||||
|
||||
<div className="tabs-list">
|
||||
{tabs.map((tab) => (
|
||||
<TabCard
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
onActivate={() => handleActivate(tab.id)}
|
||||
onClose={() => handleClose(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tabs.length === 0 && !loading && (
|
||||
<div className="empty-state">
|
||||
<p>Нет открытых вкладок</p>
|
||||
<p className="hint">
|
||||
Откройте сайт из главной страницы или выполните поиск
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveTabs;
|
||||
106
src/renderer/pages/Bookmarks.tsx
Normal file
106
src/renderer/pages/Bookmarks.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Bookmark } from '../../shared/types';
|
||||
import BookmarkCard from '../components/BookmarkCard';
|
||||
import '../styles/Bookmarks.css';
|
||||
|
||||
const Bookmarks: React.FC = () => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadBookmarks();
|
||||
}, []);
|
||||
|
||||
const loadBookmarks = async () => {
|
||||
try {
|
||||
const data = await window.electronAPI.getBookmarks();
|
||||
setBookmarks(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading bookmarks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const success = await window.electronAPI.removeBookmark(id);
|
||||
if (success) {
|
||||
setBookmarks(bookmarks.filter((b) => b.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting bookmark:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (url: string) => {
|
||||
try {
|
||||
await window.electronAPI.createTab(url);
|
||||
} catch (error) {
|
||||
console.error('Error opening bookmark:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique sites for filter
|
||||
const sites = Array.from(new Set(bookmarks.map((b) => b.siteId)));
|
||||
|
||||
const filteredBookmarks =
|
||||
filter === 'all'
|
||||
? bookmarks
|
||||
: bookmarks.filter((b) => b.siteId === filter);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Загрузка закладок...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bookmarks-page">
|
||||
<div className="bookmarks-header">
|
||||
<h2>Закладки</h2>
|
||||
<div className="bookmarks-filter">
|
||||
<label>Фильтр:</label>
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все сайты</option>
|
||||
{sites.map((siteId) => {
|
||||
const siteName =
|
||||
bookmarks.find((b) => b.siteId === siteId)?.siteName || siteId;
|
||||
return (
|
||||
<option key={siteId} value={siteId}>
|
||||
{siteName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bookmarks-grid">
|
||||
{filteredBookmarks.map((bookmark) => (
|
||||
<BookmarkCard
|
||||
key={bookmark.id}
|
||||
bookmark={bookmark}
|
||||
onOpen={() => handleOpen(bookmark.url)}
|
||||
onDelete={() => handleDelete(bookmark.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredBookmarks.length === 0 && !loading && (
|
||||
<div className="empty-state">
|
||||
<p>Нет сохраненных закладок</p>
|
||||
<p className="hint">
|
||||
Добавьте закладки через поиск или при просмотре фильмов
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmarks;
|
||||
66
src/renderer/pages/Home.tsx
Normal file
66
src/renderer/pages/Home.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SiteConfig } from '../../shared/types';
|
||||
import SiteCard from '../components/SiteCard';
|
||||
import '../styles/Home.css';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const [sites, setSites] = useState<SiteConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
}, []);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
const config = await window.electronAPI.getConfig();
|
||||
setSites(config.sites.filter((site: SiteConfig) => site.enabled));
|
||||
} catch (error) {
|
||||
console.error('Error loading sites:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiteClick = async (site: SiteConfig) => {
|
||||
try {
|
||||
await window.electronAPI.createTab(site.url);
|
||||
} catch (error) {
|
||||
console.error('Error opening site:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<h2>Доступные сайты</h2>
|
||||
<div className="sites-grid">
|
||||
{sites.map((site) => (
|
||||
<SiteCard
|
||||
key={site.id}
|
||||
site={site}
|
||||
onClick={() => handleSiteClick(site)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{sites.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Нет доступных сайтов</p>
|
||||
<p className="hint">
|
||||
Добавьте сайты через настройки или обновите конфигурацию
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
231
src/renderer/pages/Settings.tsx
Normal file
231
src/renderer/pages/Settings.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AppSettings, ProxyStatus } from '../../shared/types';
|
||||
import '../styles/Settings.css';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [proxyStatus, setProxyStatus] = useState<ProxyStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadProxyStatus();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const data = await window.electronAPI.getSettings();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProxyStatus = async () => {
|
||||
try {
|
||||
const status = await window.electronAPI.getProxyStatus();
|
||||
setProxyStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Error loading proxy status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await window.electronAPI.saveSettings(settings);
|
||||
showMessage('success', 'Настройки сохранены');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
showMessage('error', 'Ошибка при сохранении настроек');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConfig = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
await window.electronAPI.updateConfig();
|
||||
showMessage('success', 'Конфигурация обновлена успешно');
|
||||
} catch (error: any) {
|
||||
console.error('Error updating config:', error);
|
||||
showMessage('error', `Ошибка обновления: ${error.message}`);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyToggle = async () => {
|
||||
try {
|
||||
if (proxyStatus?.isRunning) {
|
||||
const newStatus = await window.electronAPI.stopProxy();
|
||||
setProxyStatus(newStatus);
|
||||
showMessage('success', 'Прокси остановлен');
|
||||
} else {
|
||||
const newStatus = await window.electronAPI.startProxy();
|
||||
setProxyStatus(newStatus);
|
||||
showMessage('success', 'Прокси запущен');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling proxy:', error);
|
||||
showMessage('error', 'Ошибка управления прокси');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
try {
|
||||
const versionInfo = await window.electronAPI.checkVersion();
|
||||
if (versionInfo.updateAvailable) {
|
||||
showMessage(
|
||||
'success',
|
||||
`Доступна новая версия: ${versionInfo.latestVersion}`
|
||||
);
|
||||
} else {
|
||||
showMessage('success', 'У вас установлена последняя версия');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking version:', error);
|
||||
showMessage('error', 'Ошибка проверки обновлений');
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (type: 'success' | 'error', text: string) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage(null), 5000);
|
||||
};
|
||||
|
||||
if (loading || !settings) {
|
||||
return (
|
||||
<div className="page-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Загрузка настроек...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h2>Настройки</h2>
|
||||
|
||||
{message && (
|
||||
<div className={`message message-${message.type}`}>{message.text}</div>
|
||||
)}
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Конфигурация сайтов</h3>
|
||||
<div className="setting-item">
|
||||
<label>URL сервера конфигураций:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.configServerUrl}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, configServerUrl: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{settings.lastConfigUpdate && (
|
||||
<p className="hint">
|
||||
Последнее обновление: {new Date(settings.lastConfigUpdate).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="button-primary"
|
||||
onClick={handleUpdateConfig}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? 'Обновление...' : 'Обновить конфигурацию'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Прокси</h3>
|
||||
<div className="setting-item">
|
||||
<label>Статус прокси:</label>
|
||||
<div className="proxy-status">
|
||||
<span
|
||||
className={`status-indicator ${
|
||||
proxyStatus?.isRunning ? 'running' : 'stopped'
|
||||
}`}
|
||||
>
|
||||
{proxyStatus?.isRunning ? '● Запущен' : '○ Остановлен'}
|
||||
</span>
|
||||
{proxyStatus?.port && <span>Порт: {proxyStatus.port}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.proxyAutoStart}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, proxyAutoStart: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Автозапуск прокси при старте приложения
|
||||
</label>
|
||||
</div>
|
||||
<button className="button-secondary" onClick={handleProxyToggle}>
|
||||
{proxyStatus?.isRunning ? 'Остановить прокси' : 'Запустить прокси'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Интерфейс</h3>
|
||||
<div className="setting-item">
|
||||
<label>Тема:</label>
|
||||
<select
|
||||
value={settings.theme}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, theme: e.target.value as 'light' | 'dark' })
|
||||
}
|
||||
>
|
||||
<option value="dark">Темная</option>
|
||||
<option value="light">Светлая</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<label>Язык:</label>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, language: e.target.value as 'ru' | 'en' })
|
||||
}
|
||||
>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Обновления</h3>
|
||||
<button className="button-secondary" onClick={handleCheckUpdate}>
|
||||
Проверить обновления
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="button-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить настройки'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
10
src/renderer/styles/ActiveTabs.css
Normal file
10
src/renderer/styles/ActiveTabs.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.tabs-page h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
81
src/renderer/styles/BookmarkCard.css
Normal file
81
src/renderer/styles/BookmarkCard.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.bookmark-card {
|
||||
position: relative;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bookmark-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.bookmark-image {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.bookmark-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bookmark-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bookmark-site {
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.bookmark-year {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.bookmark-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background-color: rgba(244, 67, 54, 0.9);
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bookmark-card:hover .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background-color: rgba(244, 67, 54, 1);
|
||||
}
|
||||
33
src/renderer/styles/Bookmarks.css
Normal file
33
src/renderer/styles/Bookmarks.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.bookmarks-page h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.bookmarks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.bookmarks-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bookmarks-filter label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bookmarks-filter select {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.bookmarks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
10
src/renderer/styles/Home.css
Normal file
10
src/renderer/styles/Home.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.home-page h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.sites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
77
src/renderer/styles/Layout.css
Normal file
77
src/renderer/styles/Layout.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px 6px 0 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
29
src/renderer/styles/SearchBar.css
Normal file
29
src/renderer/styles/SearchBar.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.search-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 0.6rem 1.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-button:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.search-button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
131
src/renderer/styles/SearchResults.css
Normal file
131
src/renderer/styles/SearchResults.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.search-results-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 40px var(--shadow);
|
||||
}
|
||||
|
||||
.search-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-results-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.search-results-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.result-image {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
object-fit: cover;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.result-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-source {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.result-year {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
86
src/renderer/styles/Settings.css
Normal file
86
src/renderer/styles/Settings.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.settings-page h2 {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.message-success {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-item label input[type='checkbox'] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.proxy-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator.running {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator.stopped {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
63
src/renderer/styles/SiteCard.css
Normal file
63
src/renderer/styles/SiteCard.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.site-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.site-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.site-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-url {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.site-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.proxy-badge {
|
||||
background-color: rgba(74, 158, 255, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.enabled-badge {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
84
src/renderer/styles/TabCard.css
Normal file
84
src/renderer/styles/TabCard.css
Normal file
@@ -0,0 +1,84 @@
|
||||
.tab-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.tab-card.active {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-favicon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-favicon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.favicon-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.25rem 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-url {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close-button {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-close-button:hover {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
69
src/renderer/styles/UpdateNotification.css
Normal file
69
src/renderer/styles/UpdateNotification.css
Normal file
@@ -0,0 +1,69 @@
|
||||
.update-notification {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
width: 400px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.update-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.update-content h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.changelog {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.changelog h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.changelog pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.update-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.update-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
158
src/renderer/styles/index.css
Normal file
158
src/renderer/styles/index.css
Normal file
@@ -0,0 +1,158 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--bg-tertiary: #3a3a3a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent: #4a9eff;
|
||||
--accent-hover: #3a8eef;
|
||||
--success: #4caf50;
|
||||
--error: #f44336;
|
||||
--border: #404040;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Common classes */
|
||||
.page-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
input[type='text'],
|
||||
input[type='url'],
|
||||
select {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type='text']:focus,
|
||||
input[type='url']:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
20
src/shared/constants.ts
Normal file
20
src/shared/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const APP_VERSION = '1.0.1';
|
||||
|
||||
export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api';
|
||||
|
||||
export const DEFAULT_PROXY_PORT = 10808;
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
configServerUrl: DEFAULT_CONFIG_SERVER_URL,
|
||||
proxyAutoStart: true,
|
||||
theme: 'dark' as const,
|
||||
language: 'ru' as const,
|
||||
};
|
||||
|
||||
export const INVISIBLE_MAN_CLI_PATH = 'invisibleManXRay'; // Path to CLI executable
|
||||
|
||||
export const DB_PATHS = {
|
||||
BOOKMARKS: 'bookmarks.db',
|
||||
SETTINGS: 'settings.db',
|
||||
CONFIG: 'config.json',
|
||||
};
|
||||
121
src/shared/types.ts
Normal file
121
src/shared/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Site Configuration Types
|
||||
export interface SiteConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
enabled: boolean;
|
||||
useProxy: boolean;
|
||||
searchScript: string; // Path to custom search script file
|
||||
}
|
||||
|
||||
export interface ConfigData {
|
||||
version: string;
|
||||
lastUpdated: string;
|
||||
sites: SiteConfig[];
|
||||
}
|
||||
|
||||
// Search Results
|
||||
export interface SearchResult {
|
||||
name: string; // Movie/Series name
|
||||
url: string; // Link to the movie page
|
||||
image?: string; // Poster image
|
||||
year?: string;
|
||||
description?: string;
|
||||
rating?: string;
|
||||
}
|
||||
|
||||
export interface SearchResultWithSource extends SearchResult {
|
||||
source: string; // Site ID
|
||||
sourceName: string; // Site Name
|
||||
}
|
||||
|
||||
// Bookmark Types
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
image?: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
createdAt: string;
|
||||
metadata?: {
|
||||
year?: string;
|
||||
description?: string;
|
||||
rating?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Active Tab Types
|
||||
export interface ActiveTab {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
favicon?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Version Check Types
|
||||
export interface VersionInfo {
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
downloadUrl: string;
|
||||
changelog: string;
|
||||
releaseDate: string;
|
||||
mandatory: boolean;
|
||||
}
|
||||
|
||||
// Proxy Types
|
||||
export interface ProxyStatus {
|
||||
isRunning: boolean;
|
||||
port?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Settings Types
|
||||
export interface AppSettings {
|
||||
configServerUrl: string;
|
||||
lastConfigUpdate?: string;
|
||||
proxyAutoStart: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
language: 'ru' | 'en';
|
||||
downloadFolder?: string;
|
||||
}
|
||||
|
||||
// IPC Channels
|
||||
export enum IPC_CHANNELS {
|
||||
// Config
|
||||
GET_CONFIG = 'config:get',
|
||||
UPDATE_CONFIG = 'config:update',
|
||||
SAVE_CONFIG = 'config:save',
|
||||
|
||||
// Search
|
||||
SEARCH_ALL_SITES = 'search:all',
|
||||
SEARCH_SITE = 'search:site',
|
||||
|
||||
// Bookmarks
|
||||
GET_BOOKMARKS = 'bookmarks:get',
|
||||
ADD_BOOKMARK = 'bookmarks:add',
|
||||
REMOVE_BOOKMARK = 'bookmarks:remove',
|
||||
|
||||
// Tabs
|
||||
GET_TABS = 'tabs:get',
|
||||
CREATE_TAB = 'tabs:create',
|
||||
CLOSE_TAB = 'tabs:close',
|
||||
ACTIVATE_TAB = 'tabs:activate',
|
||||
HIDE_ACTIVE_TAB = 'tabs:hide',
|
||||
|
||||
// Proxy
|
||||
GET_PROXY_STATUS = 'proxy:status',
|
||||
START_PROXY = 'proxy:start',
|
||||
STOP_PROXY = 'proxy:stop',
|
||||
|
||||
// Version
|
||||
CHECK_VERSION = 'version:check',
|
||||
DOWNLOAD_UPDATE = 'version:download',
|
||||
|
||||
// Settings
|
||||
GET_SETTINGS = 'settings:get',
|
||||
SAVE_SETTINGS = 'settings:save',
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/renderer"],
|
||||
"references": [{ "path": "./tsconfig.main.json" }]
|
||||
}
|
||||
19
tsconfig.main.json
Normal file
19
tsconfig.main.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist/main",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": false,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"],
|
||||
"exclude": ["node_modules", "src/renderer", "dist"]
|
||||
}
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
root: resolve(__dirname, 'src/renderer'),
|
||||
publicDir: resolve(__dirname, 'public'),
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'dist/renderer'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user