mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 01:48:50 +05:00
Измения в АПИ
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# API URL для нового Go API
|
||||||
|
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
|
||||||
|
|
||||||
|
# Для локальной разработки используйте:
|
||||||
|
# NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
94
README.md
94
README.md
@@ -1,64 +1,47 @@
|
|||||||
# 🎬 Neo Movies
|
# NeoMovies Web 🎬
|
||||||
|
|
||||||
<div align="center">
|
> Современный веб-интерфейс для поиска и просмотра фильмов и сериалов
|
||||||
<img src="public/logo.png" alt="Neo Movies Logo" width="200"/>
|
|
||||||
<p><strong>Современный онлайн-сервис с удобным интерфейсом</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 📋 О проекте
|
## 🚀 Особенности
|
||||||
|
|
||||||
Neo Movies - это современная веб-платформа построенная с использованием передовых технологий. Проект предлагает удобный интерфейс, быструю навигацию и множество функций для комфортного просмотра информации об фильмах и сералах а также стороние плееры предоставляемые видео-балансерами.
|
- 🎭 **TMDB интеграция** - полная информация о фильмах и сериалах
|
||||||
|
- 🔍 **Умный поиск** - поиск по названию, актерам, жанрам
|
||||||
### ✨ Основные возможности
|
- 🎬 **Встроенные плееры** - просмотр через Alloha и Lumex
|
||||||
|
- 🧲 **Торрент интеграция** - поиск раздач по IMDB ID
|
||||||
- 🎥 Два встроенных видеоплеера на выбор (Alloha, Lumex)
|
- ⭐ **Система избранного** - сохраняйте любимые фильмы
|
||||||
- 🔍 Умный поиск по фильмам
|
- 🎨 **Современный UI** - адаптивный дизайн с темной темой
|
||||||
- 📱 Адаптивный дизайн для всех устройств
|
- 📱 **Мобильная версия** - оптимизировано для всех устройств
|
||||||
- 🌙 Темная тема
|
- 🔐 **JWT аутентификация** - безопасная авторизация
|
||||||
- 👤 Система авторизации и профили пользователей
|
- 📧 **Email верификация** - подтверждение аккаунта
|
||||||
- ❤️ Возможность добавлять фильмы в избранное
|
|
||||||
- ⚡ Быстрая загрузка и оптимизированная производительность
|
|
||||||
|
|
||||||
## 🛠 Технологии
|
## 🛠 Технологии
|
||||||
|
|
||||||
- **Frontend:**
|
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||||
- Next.js 13+ (App Router)
|
- **Styling**: Tailwind CSS, Radix UI
|
||||||
- React 18
|
- **State Management**: Redux Toolkit
|
||||||
- TypeScript
|
- **API**: Go API (neomovies-api)
|
||||||
- Styled Components
|
- **Database**: MongoDB
|
||||||
- JWT-based authentication (custom)
|
- **Authentication**: JWT
|
||||||
|
- **Deployment**: Vercel
|
||||||
|
|
||||||
- **Backend:**
|
## 📦 Установка
|
||||||
- Node.js + Express (neomovies-api)
|
|
||||||
- MongoDB (native driver)
|
|
||||||
|
|
||||||
- **Дополнительно:**
|
1. **Клонируйте репозиторий:**
|
||||||
- ESLint
|
|
||||||
- Prettier
|
|
||||||
- Git
|
|
||||||
- npm
|
|
||||||
|
|
||||||
## Начало работы
|
|
||||||
|
|
||||||
1. Клонируйте репозиторий:
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitlab.com/foxixus/neomovies.git
|
git clone https://github.com/Ernous/neomovies-web.git
|
||||||
cd neomovies
|
cd neomovies-web
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Установите зависимости:
|
2. **Установите зависимости:**
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Создайте файл `.env` и добавьте следующие переменные:
|
3. Создайте файл `.env` и добавьте следующие переменные:
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app
|
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
|
||||||
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
|
|
||||||
NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
4. **Запустите проект:**
|
4. **Запустите проект:**
|
||||||
```bash
|
```bash
|
||||||
# Режим разработки
|
# Режим разработки
|
||||||
@@ -72,33 +55,37 @@ npm start
|
|||||||
|
|
||||||
## API (neomovies-api)
|
## API (neomovies-api)
|
||||||
|
|
||||||
Приложение использует отдельный API сервер. API предоставляет следующие возможности:
|
Приложение использует отдельный Go API сервер. API предоставляет следующие возможности:
|
||||||
|
|
||||||
- Поиск фильмов и сериалов
|
- Поиск фильмов и сериалов через TMDB
|
||||||
- Получение детальной информации о фильме/сериале
|
- Получение детальной информации о фильме/сериале
|
||||||
|
- Поиск торрентов по IMDB ID с парсингом сезонов из названий
|
||||||
|
- Система избранного и реакций
|
||||||
|
- JWT аутентификация с email верификацией
|
||||||
- Оптимизированная загрузка изображений
|
- Оптимизированная загрузка изображений
|
||||||
- Кэширование запросов
|
- Кэширование запросов
|
||||||
|
|
||||||
### Gmail App Password
|
### Особенности торрент-поиска
|
||||||
1. Включите двухфакторную аутентификацию в аккаунте Google
|
|
||||||
2. Перейдите в настройки безопасности
|
|
||||||
3. Создайте пароль приложения
|
|
||||||
4. Используйте этот пароль в GMAIL_APP_PASSWORD
|
|
||||||
|
|
||||||
Backend `.env` пример смотрите в репозитории [neomovies-api](https://gitlab.com/foxixus/neomovies-api).
|
Новый API автоматически парсит сезоны из названий торрентов, что позволяет:
|
||||||
|
- Получать реальные доступные сезоны, а не только из TMDB
|
||||||
|
- Находить раздачи даже если нумерация сезонов отличается от официальной
|
||||||
|
- Группировать торренты по сезонам для удобного выбора
|
||||||
|
|
||||||
|
Backend `.env` пример смотрите в репозитории [neomovies-api](https://github.com/Ernous/neomovies-api).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
neomovies/
|
neomovies-web/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # App Router pages
|
│ ├── app/ # App Router pages
|
||||||
│ ├── components/ # React компоненты
|
│ ├── components/ # React компоненты
|
||||||
│ ├── hooks/ # React хуки
|
│ ├── hooks/ # React хуки
|
||||||
│ ├── lib/ # Утилиты и API
|
│ ├── lib/ # Утилиты и API
|
||||||
│ ├── models/ # MongoDB модели
|
│ ├── types/ # TypeScript типы
|
||||||
│ └── styles/ # Глобальные стили
|
│ └── styles/ # Глобальные стили
|
||||||
├── public/ # Статические файлы
|
├── public/ # Статические файлы
|
||||||
└── package.json
|
└── package.json
|
||||||
@@ -108,6 +95,7 @@ neomovies/
|
|||||||
## 👥 Авторы
|
## 👥 Авторы
|
||||||
|
|
||||||
- **Frontend Developer** - [Foxix](https://gitlab.com/foxixus)
|
- **Frontend Developer** - [Foxix](https://gitlab.com/foxixus)
|
||||||
|
- **Backend Developer** - [Ernous](https://github.com/Ernous)
|
||||||
|
|
||||||
## 📄 Лицензия
|
## 📄 Лицензия
|
||||||
|
|
||||||
@@ -126,7 +114,7 @@ neomovies/
|
|||||||
## Благодарности
|
## Благодарности
|
||||||
|
|
||||||
- [TMDB](https://www.themoviedb.org/) за предоставление API
|
- [TMDB](https://www.themoviedb.org/) за предоставление API
|
||||||
- [Vercel](https://vercel.com/) за хостинг API
|
- [Vercel](https://vercel.com/) за хостинг
|
||||||
|
|
||||||
## 📞 Контакты
|
## 📞 Контакты
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,16 @@ const nextConfig = {
|
|||||||
port: '3000',
|
port: '3000',
|
||||||
pathname: '/images/**',
|
pathname: '/images/**',
|
||||||
},
|
},
|
||||||
// Продакшен на Vercel
|
// Наш API прокси для изображений
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'neomovies-api.vercel.app',
|
hostname: 'neomovies-test-api.vercel.app',
|
||||||
|
pathname: '/api/v1/images/**',
|
||||||
|
},
|
||||||
|
// Продакшен на Vercel (старый)
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'neomovies-test-api.vercel.app',
|
||||||
pathname: '/images/**',
|
pathname: '/images/**',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
839
package-lock.json
generated
839
package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "neo-movies-web",
|
"name": "neo-movies-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
"@tabler/icons-react": "^3.26.0",
|
"@tabler/icons-react": "^3.26.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
@@ -20,7 +23,9 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -28,15 +33,18 @@
|
|||||||
"mongodb": "^6.12.0",
|
"mongodb": "^6.12.0",
|
||||||
"mongoose": "^8.9.2",
|
"mongoose": "^8.9.2",
|
||||||
"next": "15.1.2",
|
"next": "15.1.2",
|
||||||
|
"next-seo": "^6.8.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"resend": "^4.0.1",
|
"resend": "^4.0.1",
|
||||||
"styled-components": "^6.1.13",
|
"styled-components": "^6.1.13",
|
||||||
"tailwind": "^4.0.0"
|
"tailwind": "^4.0.0",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -45,6 +53,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.2",
|
"eslint-config-next": "15.1.2",
|
||||||
|
"next-sitemap": "^4.2.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -72,6 +81,13 @@
|
|||||||
"regenerator-runtime": "^0.12.0"
|
"regenerator-runtime": "^0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@corex/deepmerge": {
|
||||||
|
"version": "4.0.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@corex/deepmerge/-/deepmerge-4.0.43.tgz",
|
||||||
|
"integrity": "sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
||||||
@@ -280,6 +296,44 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.3",
|
||||||
|
"@floating-ui/utils": "^0.2.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||||
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1056,6 +1110,562 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.2",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.4",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.2",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.7",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@react-email/render": {
|
"node_modules/@react-email/render": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
|
||||||
@@ -1290,7 +1900,7 @@
|
|||||||
"version": "19.1.6",
|
"version": "19.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
||||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
@@ -2128,6 +2738,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
@@ -2762,6 +3384,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/class-variance-authority": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://polar.sh/cva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -2774,6 +3408,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
@@ -3260,6 +3903,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -4652,6 +5301,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-own-enumerable-property-symbols": {
|
"node_modules/get-own-enumerable-property-symbols": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
|
||||||
@@ -6378,6 +7036,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-seo": {
|
||||||
|
"version": "6.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.8.0.tgz",
|
||||||
|
"integrity": "sha512-zcxaV67PFXCSf8e6SXxbxPaOTgc8St/esxfsYXfQXMM24UESUVSXFm7f2A9HMkAwa0Gqn4s64HxYZAGfdF4Vhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^8.1.1-canary.54 || >=9.0.0",
|
||||||
|
"react": ">=16.0.0",
|
||||||
|
"react-dom": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-sitemap": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/iamvishnusankar/next-sitemap.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@corex/deepmerge": "^4.0.43",
|
||||||
|
"@next/env": "^13.4.3",
|
||||||
|
"fast-glob": "^3.2.12",
|
||||||
|
"minimist": "^1.2.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"next-sitemap": "bin/next-sitemap.mjs",
|
||||||
|
"next-sitemap-cjs": "bin/next-sitemap.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-sitemap/node_modules/@next/env": {
|
||||||
|
"version": "13.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.11.tgz",
|
||||||
|
"integrity": "sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/next-themes": {
|
"node_modules/next-themes": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
@@ -7223,6 +7927,15 @@
|
|||||||
"react-dom": ">=16"
|
"react-dom": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -7267,6 +7980,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -8416,6 +9198,16 @@
|
|||||||
"ws": "6.2.0"
|
"ws": "6.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind/node_modules/ajv": {
|
"node_modules/tailwind/node_modules/ajv": {
|
||||||
"version": "6.10.0",
|
"version": "6.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
|
||||||
@@ -8919,6 +9711,49 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
"@tabler/icons-react": "^3.26.0",
|
"@tabler/icons-react": "^3.26.0",
|
||||||
|
|||||||
9
public/robots.txt
Normal file
9
public/robots.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# *
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Host
|
||||||
|
Host: https://neomovies.ru
|
||||||
|
|
||||||
|
# Sitemaps
|
||||||
|
Sitemap: https://neomovies.ru/sitemap.xml
|
||||||
13
public/sitemap-0.xml
Normal file
13
public/sitemap-0.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||||
|
<url><loc>https://neomovies.ru/admin/login</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/categories</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/favorites</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/login</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/profile</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/search</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/settings</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/terms</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/verify</loc><lastmod>2025-08-07T09:55:31.360Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
</urlset>
|
||||||
4
public/sitemap.xml
Normal file
4
public/sitemap.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<sitemap><loc>https://neomovies.ru/sitemap-0.xml</loc></sitemap>
|
||||||
|
</sitemapindex>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { neoApi } from '@/lib/neoApi';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -92,12 +92,12 @@ export default function AdminLoginClient() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/login', {
|
const response = await neoApi.post('/api/v1/auth/login', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { token, user } = response.data;
|
const { token, user } = response.data.data || response.data;
|
||||||
|
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
setError('У вас нет прав администратора.');
|
setError('У вас нет прав администратора.');
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { headers } from 'next/headers';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headersList = headers();
|
|
||||||
const response = await fetch(
|
|
||||||
`https://neomovies-api.vercel.app/movies/${params.id}/external-ids`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Создаем новый Response с нужными заголовками
|
|
||||||
return new NextResponse(JSON.stringify(data), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching external IDs:', error);
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Failed to fetch external IDs' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: 'https://api.themoviedb.org/3',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const id = params.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/movie/${id}`, {
|
|
||||||
params: {
|
|
||||||
language: 'ru-RU',
|
|
||||||
append_to_response: 'credits,videos,similar'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching movie details:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch movie details' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: 'https://api.themoviedb.org/3',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const page = searchParams.get('page') || '1';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.get('/discover/movie', {
|
|
||||||
params: {
|
|
||||||
page,
|
|
||||||
language: 'ru-RU',
|
|
||||||
'vote_count.gte': 100,
|
|
||||||
'vote_average.gte': 1,
|
|
||||||
sort_by: 'popularity.desc',
|
|
||||||
include_adult: false,
|
|
||||||
'primary_release_date.lte': new Date().toISOString().split('T')[0]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching popular movies:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch movies' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { searchAPI } from '@/lib/neoApi';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const query = searchParams.get('query');
|
|
||||||
const page = searchParams.get('page') || '1';
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Query parameter is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Используем обновленный multiSearch, который теперь запрашивает и фильмы, и сериалы параллельно
|
|
||||||
const response = await searchAPI.multiSearch(query, parseInt(page));
|
|
||||||
|
|
||||||
return NextResponse.json(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error searching:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Failed to search',
|
|
||||||
details: error.message || 'Unknown error'
|
|
||||||
},
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { syncMovies } from '@/lib/movieSync';
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const movies = await syncMovies();
|
|
||||||
return NextResponse.json({ success: true, movies });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing movies:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to sync movies' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const page = searchParams.get('page') || '1';
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.themoviedb.org/3/movie/top_rated?page=${page}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const page = searchParams.get('page') || '1';
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.themoviedb.org/3/movie/upcoming?page=${page}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { categoriesAPI, Movie, TVShow } from '@/lib/api';
|
import { categoriesAPI, Movie } from '@/lib/neoApi';
|
||||||
import MovieCard from '@/components/MovieCard';
|
import MovieCard from '@/components/MovieCard';
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -60,10 +60,10 @@ function CategoryPage() {
|
|||||||
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
||||||
const hasTvShows = response.data.results.length > 0;
|
const hasTvShows = response.data.results.length > 0;
|
||||||
if (page === 1) setTvShowsAvailable(hasTvShows);
|
if (page === 1) setTvShowsAvailable(hasTvShows);
|
||||||
const transformedShows = response.data.results.map((show: TVShow) => ({
|
const transformedShows = response.data.results.map((show: any) => ({
|
||||||
...show,
|
...show,
|
||||||
title: show.name,
|
title: show.name || show.title,
|
||||||
release_date: show.first_air_date,
|
release_date: show.first_air_date || show.release_date,
|
||||||
}));
|
}));
|
||||||
setItems(transformedShows);
|
setItems(transformedShows);
|
||||||
setTotalPages(response.data.total_pages);
|
setTotalPages(response.data.total_pages);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { categoriesAPI } from '@/lib/api';
|
import { categoriesAPI, Category } from '@/lib/neoApi';
|
||||||
import { Category } from '@/lib/api';
|
|
||||||
import CategoryCard from '@/components/CategoryCard';
|
import CategoryCard from '@/components/CategoryCard';
|
||||||
|
|
||||||
interface CategoryWithBackground extends Category {
|
interface CategoryWithBackground extends Category {
|
||||||
@@ -14,14 +13,12 @@ function CategoriesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Загрузка категорий и фоновых изображений для них
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCategoriesAndBackgrounds() {
|
async function fetchCategoriesAndBackgrounds() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Получаем список категорий
|
|
||||||
const categoriesResponse = await categoriesAPI.getCategories();
|
const categoriesResponse = await categoriesAPI.getCategories();
|
||||||
|
|
||||||
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
|
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
|
||||||
@@ -30,14 +27,11 @@ function CategoriesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем фоновые изображения для каждой категории
|
|
||||||
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
||||||
categoriesResponse.data.categories.map(async (category: Category) => {
|
categoriesResponse.data.categories.map(async (category: Category) => {
|
||||||
try {
|
try {
|
||||||
// Сначала пробуем получить фильм для фона
|
|
||||||
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
|
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
|
||||||
|
|
||||||
// Проверяем, есть ли фильмы в данной категории
|
|
||||||
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
|
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
|
||||||
const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
|
const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
|
||||||
moviesResponse.data.results[0].poster_path;
|
moviesResponse.data.results[0].poster_path;
|
||||||
@@ -47,7 +41,6 @@ function CategoriesPage() {
|
|||||||
backgroundUrl
|
backgroundUrl
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Если фильмов нет, пробуем получить сериалы
|
|
||||||
const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1);
|
const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1);
|
||||||
|
|
||||||
if (tvResponse.data.results && tvResponse.data.results.length > 0) {
|
if (tvResponse.data.results && tvResponse.data.results.length > 0) {
|
||||||
@@ -61,14 +54,13 @@ function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если ни фильмов, ни сериалов не найдено
|
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
backgroundUrl: undefined
|
backgroundUrl: undefined
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching background for category ${category.id}:`, error);
|
console.error(`Error fetching background for category ${category.id}:`, error);
|
||||||
return category; // Возвращаем категорию без фона в случае ошибки
|
return category;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,3 +49,11 @@ body {
|
|||||||
[data-nextjs-toast-wrapper] {
|
[data-nextjs-toast-wrapper] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { moviesAPI } from '@/lib/neoApi';
|
import { moviesAPI } from '@/lib/neoApi';
|
||||||
import { getImageUrl } from '@/lib/neoApi';
|
import { getImageUrl } from '@/lib/neoApi';
|
||||||
import type { MovieDetails } from '@/lib/api';
|
import type { MovieDetails } from '@/lib/neoApi';
|
||||||
import MoviePlayer from '@/components/MoviePlayer';
|
import MoviePlayer from '@/components/MoviePlayer';
|
||||||
import TorrentSelector from '@/components/TorrentSelector';
|
import TorrentSelector from '@/components/TorrentSelector';
|
||||||
import FavoriteButton from '@/components/FavoriteButton';
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
@@ -20,23 +20,25 @@ interface MovieContentProps {
|
|||||||
|
|
||||||
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
||||||
const [movie] = useState<MovieDetails>(initialMovie);
|
const [movie] = useState<MovieDetails>(initialMovie);
|
||||||
|
const [externalIds, setExternalIds] = useState<any>(null);
|
||||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchImdbId = async () => {
|
const fetchExternalIds = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await moviesAPI.getMovie(movieId);
|
const data = await moviesAPI.getExternalIds(movieId);
|
||||||
|
setExternalIds(data);
|
||||||
if (data?.imdb_id) {
|
if (data?.imdb_id) {
|
||||||
setImdbId(data.imdb_id);
|
setImdbId(data.imdb_id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching IMDb ID:', err);
|
console.error('Error fetching external ids:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchImdbId();
|
fetchExternalIds();
|
||||||
}, [movieId]);
|
}, [movieId]);
|
||||||
|
|
||||||
const showControls = () => {
|
const showControls = () => {
|
||||||
@@ -182,6 +184,9 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
|
|||||||
<TorrentSelector
|
<TorrentSelector
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
type="movie"
|
type="movie"
|
||||||
|
title={movie.title}
|
||||||
|
originalTitle={movie.original_title}
|
||||||
|
year={movie.release_date?.split('-')[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import MovieContent from './MovieContent';
|
import MovieContent from './MovieContent';
|
||||||
import type { MovieDetails } from '@/lib/api';
|
import type { MovieDetails } from '@/lib/neoApi';
|
||||||
|
|
||||||
interface MoviePageProps {
|
interface MoviePageProps {
|
||||||
movieId: string;
|
movieId: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { moviesAPI } from '@/lib/api';
|
import { moviesAPI } from '@/lib/neoApi';
|
||||||
import MoviePage from '@/app/movie/[id]/MoviePage';
|
import MoviePage from '@/app/movie/[id]/MoviePage';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -8,19 +8,13 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерация метаданных для страницы
|
|
||||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||||
const { params } = await props;
|
const { params } = await props;
|
||||||
// В Next.js 14, нужно сначала получить данные фильма,
|
|
||||||
// а затем использовать их для метаданных
|
|
||||||
try {
|
try {
|
||||||
// Получаем id для использования в запросе
|
|
||||||
const movieId = params.id;
|
const movieId = params.id;
|
||||||
|
|
||||||
// Запрашиваем данные фильма
|
|
||||||
const { data: movie } = await moviesAPI.getMovie(movieId);
|
const { data: movie } = await moviesAPI.getMovie(movieId);
|
||||||
|
|
||||||
// Создаем метаданные на основе полученных данных
|
|
||||||
return {
|
return {
|
||||||
title: `${movie.title} - NeoMovies`,
|
title: `${movie.title} - NeoMovies`,
|
||||||
description: movie.overview,
|
description: movie.overview,
|
||||||
@@ -33,7 +27,6 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение данных для страницы
|
|
||||||
async function getData(id: string) {
|
async function getData(id: string) {
|
||||||
try {
|
try {
|
||||||
const { data: movie } = await moviesAPI.getMovie(id);
|
const { data: movie } = await moviesAPI.getMovie(id);
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export default function HomePage() {
|
|||||||
Популярные
|
Популярные
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('now_playing')}
|
onClick={() => setActiveTab('now-playing')}
|
||||||
className={`${
|
className={`${
|
||||||
activeTab === 'now_playing'
|
activeTab === 'now-playing'
|
||||||
? 'border-red-500 text-red-600'
|
? 'border-red-500 text-red-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
@@ -59,15 +59,25 @@ export default function HomePage() {
|
|||||||
Новинки
|
Новинки
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('top_rated')}
|
onClick={() => setActiveTab('top-rated')}
|
||||||
className={`${
|
className={`${
|
||||||
activeTab === 'top_rated'
|
activeTab === 'top-rated'
|
||||||
? 'border-red-500 text-red-600'
|
? 'border-red-500 text-red-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
>
|
>
|
||||||
Топ рейтинга
|
Топ рейтинга
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('upcoming')}
|
||||||
|
className={`${
|
||||||
|
activeTab === 'upcoming'
|
||||||
|
? 'border-red-500 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||||
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
|
>
|
||||||
|
Скоро
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,24 @@
|
|||||||
|
|
||||||
import { useState, useEffect, FormEvent } from 'react';
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api';
|
import { searchAPI } from '@/lib/neoApi';
|
||||||
|
import type { Movie } from '@/lib/neoApi';
|
||||||
import MovieCard from '@/components/MovieCard';
|
import MovieCard from '@/components/MovieCard';
|
||||||
|
|
||||||
export default function SearchClient() {
|
export default function SearchClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||||
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
|
const [results, setResults] = useState<Movie[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentQuery = searchParams.get('q');
|
const currentQuery = searchParams.get('q');
|
||||||
if (currentQuery) {
|
if (currentQuery) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
searchAPI.multiSearch(currentQuery)
|
||||||
moviesAPI.searchMovies(currentQuery),
|
.then((response) => {
|
||||||
tvAPI.searchShows(currentQuery),
|
setResults(response.data.results || []);
|
||||||
])
|
|
||||||
.then(([movieResults, tvResults]) => {
|
|
||||||
const combined = [
|
|
||||||
...(movieResults.data.results || []),
|
|
||||||
...(tvResults.data.results || []),
|
|
||||||
];
|
|
||||||
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@@ -56,7 +50,7 @@ export default function SearchClient() {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
{results.map((item) => (
|
{results.map((item) => (
|
||||||
<MovieCard
|
<MovieCard
|
||||||
key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`}
|
key={`${item.id}-${item.media_type || 'movie'}`}
|
||||||
movie={item}
|
movie={item}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { tvAPI } from '@/lib/api';
|
import { tvShowsAPI } from '@/lib/neoApi';
|
||||||
import { getImageUrl } from '@/lib/neoApi';
|
import { getImageUrl } from '@/lib/neoApi';
|
||||||
import type { TVShowDetails } from '@/lib/api';
|
import type { TVShowDetails } from '@/lib/neoApi';
|
||||||
import MoviePlayer from '@/components/MoviePlayer';
|
import MoviePlayer from '@/components/MoviePlayer';
|
||||||
import TorrentSelector from '@/components/TorrentSelector';
|
import TorrentSelector from '@/components/TorrentSelector';
|
||||||
import FavoriteButton from '@/components/FavoriteButton';
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
@@ -20,29 +20,28 @@ interface TVContentProps {
|
|||||||
|
|
||||||
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||||
const [show] = useState<TVShowDetails>(initialShow);
|
const [show] = useState<TVShowDetails>(initialShow);
|
||||||
|
const [externalIds, setExternalIds] = useState<any>(null);
|
||||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchImdbId = async () => {
|
const fetchExternalIds = async () => {
|
||||||
try {
|
try {
|
||||||
// Используем dedicated эндпоинт для получения IMDb ID
|
const data = await tvShowsAPI.getExternalIds(showId);
|
||||||
const { data } = await tvAPI.getImdbId(showId);
|
setExternalIds(data);
|
||||||
if (data?.imdb_id) {
|
if (data?.imdb_id) {
|
||||||
setImdbId(data.imdb_id);
|
setImdbId(data.imdb_id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching IMDb ID:', err);
|
console.error('Error fetching external ids:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
|
|
||||||
if (initialShow.external_ids?.imdb_id) {
|
if (initialShow.external_ids?.imdb_id) {
|
||||||
setImdbId(initialShow.external_ids.imdb_id);
|
setImdbId(initialShow.external_ids.imdb_id);
|
||||||
} else {
|
} else {
|
||||||
fetchImdbId();
|
fetchExternalIds();
|
||||||
}
|
}
|
||||||
}, [showId, initialShow.external_ids]);
|
}, [showId, initialShow.external_ids]);
|
||||||
|
|
||||||
@@ -185,7 +184,9 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
|
|||||||
<TorrentSelector
|
<TorrentSelector
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
type="tv"
|
type="tv"
|
||||||
totalSeasons={show.number_of_seasons}
|
title={show.name}
|
||||||
|
originalTitle={show.original_name}
|
||||||
|
year={show.first_air_date?.split('-')[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import TVContent from '@/app/tv/[id]/TVContent';
|
import TVContent from '@/app/tv/[id]/TVContent';
|
||||||
import type { TVShowDetails } from '@/lib/api';
|
import type { TVShowDetails } from '@/lib/neoApi';
|
||||||
|
|
||||||
interface TVPageProps {
|
interface TVPageProps {
|
||||||
showId: string;
|
showId: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { tvAPI } from '@/lib/api';
|
import { tvShowsAPI } from '@/lib/neoApi';
|
||||||
import TVPage from '@/app/tv/[id]/TVPage';
|
import TVPage from '@/app/tv/[id]/TVPage';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -10,12 +10,11 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерация метаданных для страницы
|
|
||||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||||
const { params } = await props;
|
const { params } = await props;
|
||||||
try {
|
try {
|
||||||
const showId = params.id;
|
const showId = params.id;
|
||||||
const { data: show } = await tvAPI.getShow(showId);
|
const { data: show } = await tvShowsAPI.getTVShow(showId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${show.name} - NeoMovies`,
|
title: `${show.name} - NeoMovies`,
|
||||||
@@ -29,10 +28,9 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение данных для страницы
|
|
||||||
async function getData(id: string) {
|
async function getData(id: string) {
|
||||||
try {
|
try {
|
||||||
const { data: show } = await tvAPI.getShow(id);
|
const { data: show } = await tvShowsAPI.getTVShow(id);
|
||||||
return { id, show };
|
return { id, show };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error('Failed to fetch TV show');
|
throw new Error('Failed to fetch TV show');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Category } from '@/lib/api';
|
import { Category } from '@/lib/neoApi';
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { moviesAPI, api } from '@/lib/api';
|
import { moviesAPI } from '@/lib/neoApi';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
|
||||||
interface MoviePlayerProps {
|
interface MoviePlayerProps {
|
||||||
@@ -22,7 +22,10 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchImdbId = async () => {
|
const fetchImdbId = async () => {
|
||||||
if (imdbId) return;
|
if (imdbId) {
|
||||||
|
setResolvedImdb(imdbId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -40,42 +43,43 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
}, [id, imdbId]);
|
}, [id, imdbId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPlayer = async () => {
|
if (!isInitialized || !resolvedImdb) return;
|
||||||
if (!isInitialized || !resolvedImdb) return;
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex';
|
|
||||||
const { data } = await api.get(basePath, { params: { imdb_id: resolvedImdb } });
|
|
||||||
if (!data) throw new Error('Empty response');
|
|
||||||
|
|
||||||
let src: string | null = data.iframe || data.src || data.url || null;
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
if (!src && typeof data === 'string') {
|
if (!API_BASE_URL) {
|
||||||
const match = data.match(/<iframe[^>]*src="([^"]+)"/i);
|
setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.');
|
||||||
if (match && match[1]) src = match[1];
|
return;
|
||||||
}
|
}
|
||||||
if (!src) throw new Error('Invalid response format');
|
|
||||||
setIframeSrc(src);
|
const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex';
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
// Формируем URL, где imdbId является частью пути
|
||||||
setError('Не удалось загрузить плеер. Попробуйте позже.');
|
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setIframeSrc(newIframeSrc);
|
||||||
}
|
setLoading(false);
|
||||||
};
|
|
||||||
loadPlayer();
|
|
||||||
}, [resolvedImdb, isInitialized, settings.defaultPlayer]);
|
}, [resolvedImdb, isInitialized, settings.defaultPlayer]);
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!resolvedImdb) {
|
if (!resolvedImdb) {
|
||||||
// Re-fetch IMDb ID
|
|
||||||
const event = new Event('fetchImdb');
|
const event = new Event('fetchImdb');
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
} else {
|
} else {
|
||||||
// Re-load player
|
setIframeSrc(null);
|
||||||
const event = new Event('loadPlayer');
|
setLoading(true);
|
||||||
window.dispatchEvent(event);
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (!API_BASE_URL) {
|
||||||
|
setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex';
|
||||||
|
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
|
||||||
|
setIframeSrc(newIframeSrc);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,205 +1,374 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2, AlertTriangle, Copy, Check } from 'lucide-react';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react';
|
||||||
interface Torrent {
|
import { torrentsAPI, type TorrentResult } from '@/lib/neoApi';
|
||||||
magnet: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
quality?: string;
|
|
||||||
seeders?: number;
|
|
||||||
size_gb?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedTorrents {
|
|
||||||
[quality: string]: Torrent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SeasonGroupedTorrents {
|
|
||||||
[season: string]: GroupedTorrents;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TorrentSelectorProps {
|
interface TorrentSelectorProps {
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
totalSeasons?: number;
|
title?: string;
|
||||||
|
originalTitle?: string;
|
||||||
|
year?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TorrentSelector({ imdbId, type, totalSeasons }: TorrentSelectorProps) {
|
interface ParsedTorrent extends TorrentResult {
|
||||||
const [torrents, setTorrents] = useState<Torrent[] | null>(null);
|
quality?: string;
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(type === 'movie' ? 1 : null);
|
season?: number;
|
||||||
const [selectedMagnet, setSelectedMagnet] = useState<string | null>(null);
|
sizeFormatted?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TorrentSelector({ imdbId, type, title, originalTitle, year }: TorrentSelectorProps) {
|
||||||
|
const [torrents, setTorrents] = useState<ParsedTorrent[] | null>(null);
|
||||||
|
const [availableSeasons, setAvailableSeasons] = useState<number[]>([]);
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||||
|
const [selectedQuality, setSelectedQuality] = useState<string>('all');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [copiedMagnet, setCopiedMagnet] = useState<string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
// Для TV показов автоматически выбираем первый сезон
|
const parseQuality = (title: string): string => {
|
||||||
useEffect(() => {
|
const qualityRegex = /(2160p|4K|UHD|1080p|FHD|720p|HD|480p|SD|CAMRip|TS|TC|DVDRip|BDRip|WEBRip|HDTV)/i;
|
||||||
if (type === 'tv' && totalSeasons && totalSeasons > 0 && !selectedSeason) {
|
const match = title.match(qualityRegex);
|
||||||
setSelectedSeason(1);
|
if (match) {
|
||||||
|
const quality = match[1].toUpperCase();
|
||||||
|
if (quality === 'UHD' || quality === '4K') return '4K';
|
||||||
|
if (quality === 'FHD') return '1080P';
|
||||||
|
if (quality === 'HD' && !title.match(/720p/i)) return '720P';
|
||||||
|
if (quality === 'SD') return '480P';
|
||||||
|
return quality;
|
||||||
}
|
}
|
||||||
}, [type, totalSeasons, selectedSeason]);
|
return 'UNKNOWN';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSeason = (title: string): number | undefined => {
|
||||||
|
const seasonRegexes = [
|
||||||
|
/(?:S|Season\s*)(\d+)/i,
|
||||||
|
/Сезон\s*(\d+)/i,
|
||||||
|
/Season\s*(\d+)/i,
|
||||||
|
/S(\d+)E\d+/i,
|
||||||
|
/(\d+)\s*сезон/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const regex of seasonRegexes) {
|
||||||
|
const match = title.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (size: string | number): string => {
|
||||||
|
if (!size) return '';
|
||||||
|
let sizeNum = Number(size);
|
||||||
|
if (!isNaN(sizeNum) && sizeNum > 0) {
|
||||||
|
if (sizeNum > 1024 * 1024 * 1024) {
|
||||||
|
return (sizeNum / (1024 * 1024 * 1024)).toFixed(2) + ' ГБ';
|
||||||
|
} else if (sizeNum > 1024 * 1024) {
|
||||||
|
return (sizeNum / (1024 * 1024)).toFixed(2) + ' МБ';
|
||||||
|
} else {
|
||||||
|
return (sizeNum / 1024).toFixed(2) + ' КБ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === 'tv' && torrents && torrents.length > 0) {
|
||||||
|
const seasons = [...new Set(
|
||||||
|
torrents
|
||||||
|
.map(t => t.season)
|
||||||
|
.filter(s => s !== undefined)
|
||||||
|
.sort((a, b) => a! - b!)
|
||||||
|
)] as number[];
|
||||||
|
setAvailableSeasons(seasons);
|
||||||
|
}
|
||||||
|
}, [type, torrents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imdbId) return;
|
if (!imdbId) return;
|
||||||
|
fetchTorrents();
|
||||||
// Для фильмов загружаем сразу
|
}, [imdbId, type]);
|
||||||
if (type === 'movie') {
|
|
||||||
fetchTorrents();
|
|
||||||
}
|
|
||||||
// Для TV показов загружаем только когда выбран сезон
|
|
||||||
else if (type === 'tv' && selectedSeason) {
|
|
||||||
fetchTorrents();
|
|
||||||
}
|
|
||||||
}, [imdbId, type, selectedSeason]);
|
|
||||||
|
|
||||||
const fetchTorrents = async () => {
|
const fetchTorrents = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedMagnet(null);
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
const response = await torrentsAPI.searchTorrents(imdbId!, type);
|
||||||
if (!apiUrl) {
|
if (response.data.total === 0) {
|
||||||
throw new Error('API URL не настроен');
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = `${apiUrl}/torrents/search/${imdbId}?type=${type}`;
|
|
||||||
|
|
||||||
if (type === 'tv' && selectedSeason) {
|
|
||||||
url += `&season=${selectedSeason}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('API URL:', url, 'IMDB:', imdbId, 'season:', selectedSeason);
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch torrents');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.total === 0) {
|
|
||||||
setError('Торренты не найдены.');
|
setError('Торренты не найдены.');
|
||||||
} else {
|
} else {
|
||||||
setTorrents(data.results as Torrent[]);
|
const parsedTorrents: ParsedTorrent[] = response.data.results.map(torrent => ({
|
||||||
|
...torrent,
|
||||||
|
quality: parseQuality(torrent.title || ''),
|
||||||
|
season: type === 'tv' ? parseSeason(torrent.title || '') : undefined,
|
||||||
|
sizeFormatted: formatSize(torrent.size || 0)
|
||||||
|
}));
|
||||||
|
setTorrents(parsedTorrents);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
|
||||||
setError('Не удалось загрузить список торрентов.');
|
setError('Не удалось загрузить список торрентов.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQualitySelect = (torrent: Torrent) => {
|
const filteredTorrents = useMemo(() => {
|
||||||
setSelectedMagnet(torrent.magnet);
|
if (!torrents) return [];
|
||||||
setIsCopied(false);
|
|
||||||
|
let filtered = torrents;
|
||||||
|
|
||||||
|
if (type === 'tv' && selectedSeason !== null && availableSeasons.length > 0) {
|
||||||
|
filtered = filtered.filter(torrent => torrent.season === selectedSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedQuality !== 'all') {
|
||||||
|
filtered = filtered.filter(torrent => torrent.quality === selectedQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
const qualityOrder = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP'];
|
||||||
|
const aQualityIndex = qualityOrder.indexOf(a.quality || '');
|
||||||
|
const bQualityIndex = qualityOrder.indexOf(b.quality || '');
|
||||||
|
|
||||||
|
if (aQualityIndex === -1 && bQualityIndex === -1) return 0;
|
||||||
|
if (aQualityIndex === -1) return 1;
|
||||||
|
if (bQualityIndex === -1) return -1;
|
||||||
|
return aQualityIndex - bQualityIndex;
|
||||||
|
});
|
||||||
|
}, [torrents, selectedSeason, selectedQuality, type, availableSeasons]);
|
||||||
|
|
||||||
|
const availableQualities = useMemo(() => {
|
||||||
|
if (!torrents) return [];
|
||||||
|
const qualities = [...new Set(torrents.map(t => t.quality).filter(q => q && q !== 'UNKNOWN'))];
|
||||||
|
return qualities.sort((a, b) => {
|
||||||
|
const order = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP', 'CAMRIP', 'TS', 'TC'];
|
||||||
|
const indexA = order.indexOf(a!);
|
||||||
|
const indexB = order.indexOf(b!);
|
||||||
|
if (indexA === -1 && indexB === -1) return a!.localeCompare(b!);
|
||||||
|
if (indexA === -1) return 1;
|
||||||
|
if (indexB === -1) return -1;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
}, [torrents]);
|
||||||
|
|
||||||
|
const handleCopy = (magnet: string) => {
|
||||||
|
navigator.clipboard.writeText(magnet);
|
||||||
|
setCopiedMagnet(magnet);
|
||||||
|
setTimeout(() => setCopiedMagnet(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleDownload = (magnet: string) => {
|
||||||
if (!selectedMagnet) return;
|
window.open(magnet, '_blank');
|
||||||
navigator.clipboard.writeText(selectedMagnet);
|
};
|
||||||
setIsCopied(true);
|
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
const TorrentCard = ({ torrent }: { torrent: ParsedTorrent }) => {
|
||||||
|
const getAdditionalInfo = (title: string) => {
|
||||||
|
const info: string[] = [];
|
||||||
|
|
||||||
|
if (title.match(/rus/i)) info.push('RUS');
|
||||||
|
if (title.match(/eng/i)) info.push('ENG');
|
||||||
|
if (title.match(/x264/i)) info.push('x264');
|
||||||
|
if (title.match(/x265|HEVC/i)) info.push('HEVC');
|
||||||
|
if (title.match(/HDR/i)) info.push('HDR');
|
||||||
|
if (title.match(/Dolby/i)) info.push('Dolby');
|
||||||
|
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionalInfo = getAdditionalInfo(torrent.title || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 space-y-3 bg-white dark:bg-zinc-800 hover:bg-gray-50 transition-colors dark:hover:bg-zinc-700">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-sm leading-tight break-words line-clamp-2 text-gray-900 dark:text-gray-50">
|
||||||
|
{torrent.title || 'Раздача'}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{torrent.quality && torrent.quality !== 'UNKNOWN' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||||
|
{torrent.quality}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{type === 'tv' && torrent.season && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Сезон {torrent.season}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{torrent.sizeFormatted && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-50 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||||
|
{torrent.sizeFormatted}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{additionalInfo.map(info => (
|
||||||
|
<span key={info} className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{info}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCopy(torrent.magnet)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 border-gray-300 text-gray-700 dark:border-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{copiedMagnet === torrent.magnet ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-2 text-green-500" />
|
||||||
|
Скопировано
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Копировать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload(torrent.magnet)}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white dark:bg-blue-700 dark:hover:bg-blue-800"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-center p-4">
|
<div className="mt-4 flex items-center justify-center p-4">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-gray-500 dark:text-gray-400" />
|
||||||
<span>Загрузка торрентов...</span>
|
<span className="text-gray-700 dark:text-gray-300">Загрузка торрентов...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-100 p-3 text-sm text-red-700">
|
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-400">
|
||||||
<AlertTriangle size={20} />
|
<AlertTriangle size={20} className="text-red-600 dark:text-red-500" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!torrents) return null;
|
if (!torrents || torrents.length === 0) {
|
||||||
|
return null;
|
||||||
const renderTorrentButtons = (list: Torrent[]) => {
|
}
|
||||||
if (!list?.length) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Торрентов для выбранного сезона нет.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.map(torrent => {
|
|
||||||
const size = torrent.size_gb;
|
|
||||||
const label = torrent.title || torrent.name || 'Раздача';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={torrent.magnet}
|
|
||||||
asChild
|
|
||||||
onClick={() => handleQualitySelect(torrent)}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full items-center text-left px-3 py-2"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={torrent.magnet}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex w-full items-center"
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate whitespace-nowrap overflow-hidden">{label}</span>
|
|
||||||
{size !== undefined && (
|
|
||||||
<span className="text-xs text-muted-foreground">{size.toFixed(2)} GB</span>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4">
|
||||||
{type === 'tv' && totalSeasons && totalSeasons > 0 && (
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<div>
|
<DialogTrigger asChild>
|
||||||
<h3 className="text-lg font-semibold mb-2">Сезоны</h3>
|
<Button className="w-full sm:w-auto" size="lg">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Download className="h-4 w-4 mr-2" />
|
||||||
{Array.from({ length: totalSeasons }, (_, i) => i + 1).map(season => (
|
Скачать ({torrents.length} {torrents.length === 1 ? 'раздача' : torrents.length < 5 ? 'раздачи' : 'раздач'})
|
||||||
<Button
|
</Button>
|
||||||
key={season}
|
</DialogTrigger>
|
||||||
onClick={() => {setSelectedSeason(season); setSelectedMagnet(null);}}
|
|
||||||
variant={selectedSeason === season ? 'default' : 'outline'}
|
|
||||||
>
|
|
||||||
Сезон {season}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedSeason && torrents && (
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-zinc-900">
|
||||||
<div>
|
<DialogHeader>
|
||||||
<h3 className="text-lg font-semibold mb-2">Раздачи</h3>
|
<DialogTitle className="text-gray-900 dark:text-gray-50">
|
||||||
<div className="space-y-2">
|
Выберите раздачу для скачивания
|
||||||
{renderTorrentButtons(torrents)}
|
</DialogTitle>
|
||||||
</div>
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Найдено {filteredTorrents.length} из {torrents.length} раздач
|
||||||
)}
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
{selectedMagnet && (
|
|
||||||
<div className="mt-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold mb-2">Magnet-ссылка</h3>
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
{availableQualities.length > 0 && (
|
||||||
<div className="flex-1 rounded-md border bg-secondary/50 px-3 py-2 text-sm">
|
<div>
|
||||||
{selectedMagnet}
|
<label className="text-sm font-medium mb-3 block text-gray-900 dark:text-gray-50">Качество</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedQuality === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedQuality('all')}
|
||||||
|
className={selectedQuality === 'all' ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
||||||
|
>
|
||||||
|
Все ({torrents.length})
|
||||||
|
</Button>
|
||||||
|
{availableQualities.map(quality => {
|
||||||
|
const count = torrents.filter(t =>
|
||||||
|
t.quality === quality &&
|
||||||
|
(type !== 'tv' || selectedSeason === null || availableSeasons.length === 0 || t.season === selectedSeason)
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={quality}
|
||||||
|
variant={selectedQuality === quality ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedQuality(quality!)}
|
||||||
|
className={selectedQuality === quality ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
||||||
|
>
|
||||||
|
{quality} ({count})
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'tv' && availableSeasons.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-sm font-medium mb-3 block text-gray-900 dark:text-gray-50">Сезон</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedSeason === null ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedSeason(null)}
|
||||||
|
className={selectedSeason === null ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
||||||
|
>
|
||||||
|
Все сезоны
|
||||||
|
</Button>
|
||||||
|
{availableSeasons.map(season => {
|
||||||
|
const count = torrents?.filter(t => t.season === season && (selectedQuality === 'all' || t.quality === selectedQuality)).length || 0;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={season}
|
||||||
|
variant={selectedSeason === season ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedSeason(season)}
|
||||||
|
className={selectedSeason === season ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
||||||
|
>
|
||||||
|
Сезон {season} ({count})
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTorrents.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
Нет раздач, соответствующих выбранным фильтрам
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTorrents.map((torrent, index) => (
|
||||||
|
<TorrentCard key={`${torrent.magnet}-${index}`} torrent={torrent} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCopy} size="icon" variant="outline">
|
|
||||||
{isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
)}
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
160
src/components/ui/select.tsx
Normal file
160
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { authAPI } from '../lib/authApi';
|
import { authAPI } from '../lib/authApi';
|
||||||
import { api } from '../lib/api';
|
import { neoApi } from '../lib/neoApi';
|
||||||
|
|
||||||
interface PendingRegistration {
|
interface PendingRegistration {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -16,34 +16,35 @@ export function useAuth() {
|
|||||||
const [pending, setPending] = useState<PendingRegistration | null>(null);
|
const [pending, setPending] = useState<PendingRegistration | null>(null);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
const { data } = await authAPI.login(email, password);
|
try {
|
||||||
if (data?.token) {
|
const response = await authAPI.login(email, password);
|
||||||
localStorage.setItem('token', data.token);
|
const data = response.data.data || response.data;
|
||||||
|
if (data?.token) {
|
||||||
// Extract name/email either from API response or JWT payload
|
localStorage.setItem('token', data.token);
|
||||||
let name: string | undefined = undefined;
|
let name: string | undefined = undefined;
|
||||||
let email: string | undefined = undefined;
|
let emailVal: string | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(data.token.split('.')[1]));
|
const payload = JSON.parse(atob(data.token.split('.')[1]));
|
||||||
name = payload.name || payload.username || payload.userName || payload.sub || undefined;
|
name = payload.name || payload.username || payload.userName || payload.sub || undefined;
|
||||||
email = payload.email || undefined;
|
emailVal = payload.email || undefined;
|
||||||
} catch {
|
} catch {}
|
||||||
// silent
|
if (!name) name = data.user?.name || data.name || data.userName;
|
||||||
|
if (!emailVal) emailVal = data.user?.email || data.email;
|
||||||
|
if (name) localStorage.setItem('userName', name);
|
||||||
|
if (emailVal) localStorage.setItem('userEmail', emailVal);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('auth-changed'));
|
||||||
|
}
|
||||||
|
neoApi.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.error || 'Login failed');
|
||||||
}
|
}
|
||||||
if (!name) name = data.user?.name || data.name || data.userName;
|
} catch (err: any) {
|
||||||
if (!email) email = data.user?.email || data.email;
|
if (err?.response?.status === 401 || err?.response?.status === 400) {
|
||||||
|
throw new Error('Неверный логин или пароль');
|
||||||
if (name) localStorage.setItem('userName', name);
|
|
||||||
if (email) localStorage.setItem('userEmail', email);
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(new Event('auth-changed'));
|
|
||||||
}
|
}
|
||||||
|
throw new Error(err?.message || 'Произошла ошибка');
|
||||||
api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
|
|
||||||
router.push('/');
|
|
||||||
} else {
|
|
||||||
throw new Error(data?.error || 'Login failed');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,14 +67,11 @@ export function useAuth() {
|
|||||||
setPending(pendingData);
|
setPending(pendingData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingData) {
|
if (!pendingData) {
|
||||||
throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.');
|
throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await authAPI.verify(pendingData.email, code);
|
await authAPI.verify(pendingData.email, code);
|
||||||
await login(pendingData.email, pendingData.password);
|
await login(pendingData.email, pendingData.password);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('pendingVerification');
|
localStorage.removeItem('pendingVerification');
|
||||||
}
|
}
|
||||||
@@ -83,7 +81,7 @@ export function useAuth() {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
delete api.defaults.headers.common['Authorization'];
|
delete neoApi.defaults.headers.common['Authorization'];
|
||||||
localStorage.removeItem('userName');
|
localStorage.removeItem('userName');
|
||||||
localStorage.removeItem('userEmail');
|
localStorage.removeItem('userEmail');
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { moviesAPI } from '@/lib/neoApi';
|
import { moviesAPI } from '@/lib/neoApi';
|
||||||
import type { Movie, MovieResponse } from '@/lib/neoApi';
|
import type { Movie, MovieResponse } from '@/lib/neoApi';
|
||||||
|
|
||||||
export type MovieCategory = 'popular' | 'top_rated' | 'now_playing';
|
export type MovieCategory = 'popular' | 'top-rated' | 'now-playing' | 'upcoming';
|
||||||
|
|
||||||
interface UseMoviesProps {
|
interface UseMoviesProps {
|
||||||
initialPage?: number;
|
initialPage?: number;
|
||||||
@@ -26,12 +26,15 @@ export function useMovies({ initialPage = 1, category = 'popular' }: UseMoviesPr
|
|||||||
let response: { data: MovieResponse };
|
let response: { data: MovieResponse };
|
||||||
|
|
||||||
switch (movieCategory) {
|
switch (movieCategory) {
|
||||||
case 'top_rated':
|
case 'top-rated':
|
||||||
response = await moviesAPI.getTopRated(pageNum);
|
response = await moviesAPI.getTopRated(pageNum);
|
||||||
break;
|
break;
|
||||||
case 'now_playing':
|
case 'now-playing':
|
||||||
response = await moviesAPI.getNowPlaying(pageNum);
|
response = await moviesAPI.getNowPlaying(pageNum);
|
||||||
break;
|
break;
|
||||||
|
case 'upcoming':
|
||||||
|
response = await moviesAPI.getUpcoming(pageNum);
|
||||||
|
break;
|
||||||
case 'popular':
|
case 'popular':
|
||||||
default:
|
default:
|
||||||
response = await moviesAPI.getPopular(pageNum);
|
response = await moviesAPI.getPopular(pageNum);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { moviesAPI } from '@/lib/api';
|
import { searchAPI } from '@/lib/neoApi';
|
||||||
import type { Movie } from '@/lib/api';
|
import type { Movie } from '@/lib/neoApi';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export function useSearch() {
|
|||||||
setCurrentQuery(query);
|
setCurrentQuery(query);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
|
||||||
const response = await moviesAPI.searchMovies(query, 1);
|
const response = await searchAPI.multiSearch(query, 1);
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
const filteredMovies = filterMovies(response.data.results);
|
||||||
|
|
||||||
if (filteredMovies.length === 0) {
|
if (filteredMovies.length === 0) {
|
||||||
@@ -74,7 +74,7 @@ export function useSearch() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const nextPage = currentPage + 1;
|
const nextPage = currentPage + 1;
|
||||||
|
|
||||||
const response = await moviesAPI.searchMovies(currentQuery, nextPage);
|
const response = await searchAPI.multiSearch(currentQuery, nextPage);
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
const filteredMovies = filterMovies(response.data.results);
|
||||||
|
|
||||||
setResults(prev => [...prev, ...filteredMovies]);
|
setResults(prev => [...prev, ...filteredMovies]);
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import type { Movie } from '@/lib/api';
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: '/api/bridge/tmdb',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useTMDBMovies(initialPage = 1) {
|
|
||||||
const [movies, setMovies] = useState<Movie[]>([]);
|
|
||||||
const [featuredMovie, setFeaturedMovie] = useState<Movie | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [page, setPage] = useState(initialPage);
|
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
|
||||||
|
|
||||||
const filterMovies = useCallback((movies: Movie[]) => {
|
|
||||||
return movies.filter(movie => {
|
|
||||||
if (movie.vote_average === 0) return false;
|
|
||||||
const hasRussianLetters = /[а-яА-ЯёЁ]/.test(movie.title);
|
|
||||||
if (!hasRussianLetters) return false;
|
|
||||||
if (/^\d+$/.test(movie.title)) return false;
|
|
||||||
const releaseDate = new Date(movie.release_date);
|
|
||||||
const now = new Date();
|
|
||||||
if (releaseDate > now) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchFeaturedMovie = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/movie/popular', {
|
|
||||||
params: {
|
|
||||||
page: 1,
|
|
||||||
language: 'ru-RU'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
|
||||||
if (filteredMovies.length > 0) {
|
|
||||||
const featuredMovieData = await api.get(`/movie/${filteredMovies[0].id}`, {
|
|
||||||
params: {
|
|
||||||
language: 'ru-RU',
|
|
||||||
append_to_response: 'credits,videos'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setFeaturedMovie(featuredMovieData.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке featured фильма:', err);
|
|
||||||
}
|
|
||||||
}, [filterMovies]);
|
|
||||||
|
|
||||||
const fetchMovies = useCallback(async (pageNum: number) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await api.get('/discover/movie', {
|
|
||||||
params: {
|
|
||||||
page: pageNum,
|
|
||||||
language: 'ru-RU',
|
|
||||||
'vote_count.gte': 100,
|
|
||||||
'vote_average.gte': 1,
|
|
||||||
sort_by: 'popularity.desc',
|
|
||||||
include_adult: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
|
||||||
setMovies(filteredMovies);
|
|
||||||
setTotalPages(response.data.total_pages);
|
|
||||||
setPage(pageNum);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке фильмов:', err);
|
|
||||||
setError('Произошла ошибка при загрузке фильмов');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filterMovies]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFeaturedMovie();
|
|
||||||
}, [fetchFeaturedMovie]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMovies(page);
|
|
||||||
}, [page, fetchMovies]);
|
|
||||||
|
|
||||||
const handlePageChange = useCallback((newPage: number) => {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
setPage(newPage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchMovies = useCallback(async (query: string, pageNum: number = 1) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await api.get('/search/movie', {
|
|
||||||
params: {
|
|
||||||
query,
|
|
||||||
page: pageNum,
|
|
||||||
language: 'ru-RU',
|
|
||||||
include_adult: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
|
||||||
setMovies(filteredMovies);
|
|
||||||
setTotalPages(response.data.total_pages);
|
|
||||||
setPage(pageNum);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при поиске фильмов:', err);
|
|
||||||
setError('Произошла ошибка при поиске фильмов');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filterMovies]);
|
|
||||||
|
|
||||||
const getUpcomingMovies = useCallback(async (pageNum: number = 1) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await api.get('/movie/upcoming', {
|
|
||||||
params: {
|
|
||||||
page: pageNum,
|
|
||||||
language: 'ru-RU',
|
|
||||||
'vote_count.gte': 100,
|
|
||||||
'vote_average.gte': 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
|
||||||
setMovies(filteredMovies);
|
|
||||||
setTotalPages(response.data.total_pages);
|
|
||||||
setPage(pageNum);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке предстоящих фильмов:', err);
|
|
||||||
setError('Произошла ошибка при загрузке предстоящих фильмов');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filterMovies]);
|
|
||||||
|
|
||||||
const getTopRatedMovies = useCallback(async (pageNum: number = 1) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await api.get('/movie/top_rated', {
|
|
||||||
params: {
|
|
||||||
page: pageNum,
|
|
||||||
language: 'ru-RU',
|
|
||||||
'vote_count.gte': 100,
|
|
||||||
'vote_average.gte': 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
|
||||||
setMovies(filteredMovies);
|
|
||||||
setTotalPages(response.data.total_pages);
|
|
||||||
setPage(pageNum);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при загрузке топ фильмов:', err);
|
|
||||||
setError('Произошла ошибка при загрузке топ фильмов');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filterMovies]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
movies,
|
|
||||||
featuredMovie,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
totalPages,
|
|
||||||
currentPage: page,
|
|
||||||
setPage: handlePageChange,
|
|
||||||
searchMovies,
|
|
||||||
getUpcomingMovies,
|
|
||||||
getTopRatedMovies
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,7 @@ export function useUser() {
|
|||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
// Сначала проверяем, верифицирован ли аккаунт
|
const verificationCheck = await fetch('/api/v1/auth/check-verification', {
|
||||||
const verificationCheck = await fetch('/api/auth/check-verification', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email })
|
body: JSON.stringify({ email })
|
||||||
@@ -27,8 +26,7 @@ export function useUser() {
|
|||||||
const { isVerified } = await verificationCheck.json();
|
const { isVerified } = await verificationCheck.json();
|
||||||
|
|
||||||
if (!isVerified) {
|
if (!isVerified) {
|
||||||
// Если аккаунт не верифицирован, отправляем новый код и переходим к верификации
|
const verificationResponse = await fetch('/api/v1/auth/verify', {
|
||||||
const verificationResponse = await fetch('/api/auth/verify', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email })
|
body: JSON.stringify({ email })
|
||||||
@@ -43,15 +41,28 @@ export function useUser() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если аккаунт верифицирован, выполняем вход
|
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||||
const result = await signIn('credentials', {
|
method: 'POST',
|
||||||
redirect: false,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
email,
|
body: JSON.stringify({ email, password })
|
||||||
password,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (!loginResponse.ok) {
|
||||||
throw new Error(result.error);
|
const data = await loginResponse.json();
|
||||||
|
throw new Error(data.error || 'Неверный email или пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
const { token, user } = loginData.data || loginData;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
if (user?.name) localStorage.setItem('userName', user.name);
|
||||||
|
if (user?.email) localStorage.setItem('userEmail', user.email);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('auth-changed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
@@ -62,7 +73,7 @@ export function useUser() {
|
|||||||
|
|
||||||
const register = async (email: string, password: string, name: string) => {
|
const register = async (email: string, password: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/register', {
|
const response = await fetch('/api/v1/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password, name }),
|
body: JSON.stringify({ email, password, name }),
|
||||||
@@ -73,8 +84,7 @@ export function useUser() {
|
|||||||
throw new Error(data.error || 'Ошибка при регистрации');
|
throw new Error(data.error || 'Ошибка при регистрации');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправляем код подтверждения
|
const verificationResponse = await fetch('/api/v1/auth/verify', {
|
||||||
const verificationResponse = await fetch('/api/auth/verify', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email })
|
body: JSON.stringify({ email })
|
||||||
@@ -98,7 +108,7 @@ export function useUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/verify', {
|
const response = await fetch('/api/v1/auth/verify', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -112,15 +122,31 @@ export function useUser() {
|
|||||||
throw new Error(data.error || 'Неверный код подтверждения');
|
throw new Error(data.error || 'Неверный код подтверждения');
|
||||||
}
|
}
|
||||||
|
|
||||||
// После успешной верификации выполняем вход
|
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||||
const result = await signIn('credentials', {
|
method: 'POST',
|
||||||
redirect: false,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
email: pendingRegistration.email,
|
body: JSON.stringify({
|
||||||
password: pendingRegistration.password,
|
email: pendingRegistration.email,
|
||||||
|
password: pendingRegistration.password
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (!loginResponse.ok) {
|
||||||
throw new Error(result.error);
|
const data = await loginResponse.json();
|
||||||
|
throw new Error(data.error || 'Ошибка входа после верификации');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
const { token, user } = loginData.data || loginData;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
if (user?.name) localStorage.setItem('userName', user.name);
|
||||||
|
if (user?.email) localStorage.setItem('userEmail', user.email);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('auth-changed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
@@ -132,7 +158,15 @@ export function useUser() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
signOut({ callbackUrl: '/login' });
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('userName');
|
||||||
|
localStorage.removeItem('userEmail');
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('auth-changed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
262
src/lib/api.ts
262
src/lib/api.ts
@@ -1,262 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
if (!API_URL) {
|
|
||||||
throw new Error('NEXT_PUBLIC_API_URL is not defined in environment variables');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = axios.create({
|
|
||||||
baseURL: API_URL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach JWT token if present in localStorage
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stored token on login response with { token }
|
|
||||||
api.interceptors.response.use((response) => {
|
|
||||||
if (response.config.url?.includes('/auth/login') && response.data?.token) {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('token', response.data.token);
|
|
||||||
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Genre {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Movie {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
overview: string;
|
|
||||||
poster_path: string | null;
|
|
||||||
backdrop_path: string | null;
|
|
||||||
release_date: string;
|
|
||||||
vote_average: number;
|
|
||||||
vote_count: number;
|
|
||||||
genre_ids: number[];
|
|
||||||
runtime?: number;
|
|
||||||
genres?: Array<{ id: number; name: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MovieDetails extends Movie {
|
|
||||||
genres: Genre[];
|
|
||||||
runtime: number;
|
|
||||||
imdb_id?: string | null;
|
|
||||||
tagline: string;
|
|
||||||
budget: number;
|
|
||||||
revenue: number;
|
|
||||||
videos: {
|
|
||||||
results: Video[];
|
|
||||||
};
|
|
||||||
credits: {
|
|
||||||
cast: Cast[];
|
|
||||||
crew: Crew[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TVShow {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
overview: string;
|
|
||||||
poster_path: string | null;
|
|
||||||
backdrop_path: string | null;
|
|
||||||
first_air_date: string;
|
|
||||||
vote_average: number;
|
|
||||||
vote_count: number;
|
|
||||||
genre_ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TVShowDetails extends TVShow {
|
|
||||||
genres: Genre[];
|
|
||||||
number_of_episodes: number;
|
|
||||||
number_of_seasons: number;
|
|
||||||
tagline: string;
|
|
||||||
credits: {
|
|
||||||
cast: Cast[];
|
|
||||||
crew: Crew[];
|
|
||||||
};
|
|
||||||
seasons: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
episode_count: number;
|
|
||||||
poster_path: string | null;
|
|
||||||
}>;
|
|
||||||
external_ids?: {
|
|
||||||
imdb_id: string | null;
|
|
||||||
tvdb_id: number | null;
|
|
||||||
tvrage_id: number | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Video {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
site: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Cast {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
character: string;
|
|
||||||
profile_path: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Crew {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
job: string;
|
|
||||||
profile_path: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MovieResponse {
|
|
||||||
page: number;
|
|
||||||
results: Movie[];
|
|
||||||
total_pages: number;
|
|
||||||
total_results: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TVShowResponse {
|
|
||||||
page: number;
|
|
||||||
results: TVShow[];
|
|
||||||
total_pages: number;
|
|
||||||
total_results: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const categoriesAPI = {
|
|
||||||
// Получение всех категорий
|
|
||||||
getCategories() {
|
|
||||||
return api.get<{ categories: Category[] }>('/categories');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение категории по ID
|
|
||||||
getCategory(id: number) {
|
|
||||||
return api.get<Category>(`/categories/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение фильмов по категории
|
|
||||||
getMoviesByCategory(categoryId: number, page = 1) {
|
|
||||||
return api.get<MovieResponse>(`/categories/${categoryId}/movies`, {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение сериалов по категории
|
|
||||||
getTVShowsByCategory(categoryId: number, page = 1) {
|
|
||||||
return api.get<TVShowResponse>(`/categories/${categoryId}/tv`, {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const moviesAPI = {
|
|
||||||
// Получение популярных фильмов
|
|
||||||
getPopular(page = 1) {
|
|
||||||
return api.get<MovieResponse>('/movies/popular', {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение данных о фильме по его TMDB ID
|
|
||||||
getMovie(id: string | number) {
|
|
||||||
return api.get<MovieDetails>(`/movies/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение IMDb ID по TMDB ID для плеера
|
|
||||||
getImdbId(tmdbId: string | number) {
|
|
||||||
return api.get<{ imdb_id: string }>(`/movies/${tmdbId}/external-ids`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение видео по TMDB ID для плеера
|
|
||||||
getVideo(tmdbId: string | number) {
|
|
||||||
return api.get<{ results: Video[] }>(`/movies/${tmdbId}/videos`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Поиск фильмов
|
|
||||||
searchMovies(query: string, page = 1) {
|
|
||||||
return api.get<MovieResponse>('/movies/search', {
|
|
||||||
params: { query, page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение предстоящих фильмов
|
|
||||||
getUpcoming(page = 1) {
|
|
||||||
return api.get<MovieResponse>('/movies/upcoming', {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение лучших фильмов
|
|
||||||
getTopRated(page = 1) {
|
|
||||||
return api.get<MovieResponse>('/movies/top-rated', {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение фильмов по жанру
|
|
||||||
getMoviesByGenre(genreId: number, page = 1) {
|
|
||||||
return api.get<MovieResponse>('/movies/discover', {
|
|
||||||
params: { with_genres: genreId, page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение жанров
|
|
||||||
getGenres() {
|
|
||||||
return api.get<{ genres: Genre[] }>('/movies/genres');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tvAPI = {
|
|
||||||
// Получение популярных сериалов
|
|
||||||
getPopular(page = 1) {
|
|
||||||
return api.get<TVShowResponse>('/tv/popular', {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение данных о сериале по его TMDB ID
|
|
||||||
getShow(id: string | number) {
|
|
||||||
return api.get<TVShowDetails>(`/tv/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Получение IMDb ID по TMDB ID для плеера
|
|
||||||
getImdbId(tmdbId: string | number) {
|
|
||||||
return api.get<{ imdb_id: string }>(`/tv/${tmdbId}/external-ids`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Поиск сериалов
|
|
||||||
searchShows(query: string, page = 1) {
|
|
||||||
return api.get<TVShowResponse>('/tv/search', {
|
|
||||||
params: { query, page }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Мультипоиск (фильмы и сериалы)
|
|
||||||
export const searchAPI = {
|
|
||||||
multiSearch(query: string, page = 1) {
|
|
||||||
return api.get('/search/multi', {
|
|
||||||
params: { query, page }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import { api } from './api';
|
import { neoApi } from './neoApi';
|
||||||
|
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
register(data: { email: string; password: string; name?: string }) {
|
register: (data: any) => neoApi.post('/api/v1/auth/register', data),
|
||||||
return api.post('/auth/register', data);
|
resendCode: (email: string) => neoApi.post('/api/v1/auth/resend-code', { email }),
|
||||||
},
|
verify: (email: string, code: string) => neoApi.post('/api/v1/auth/verify', { email, code }),
|
||||||
resendCode(email: string) {
|
checkVerification: (email: string) => neoApi.post('/api/v1/auth/check-verification', { email }),
|
||||||
return api.post('/auth/resend-code', { email });
|
login: (email: string, password: string) => neoApi.post('/api/v1/auth/login', { email, password }),
|
||||||
},
|
deleteAccount: () => neoApi.delete('/api/v1/auth/profile'),
|
||||||
verify(email: string, code: string) {
|
|
||||||
return api.post('/auth/verify', { email, code });
|
|
||||||
},
|
|
||||||
login(email: string, password: string) {
|
|
||||||
return api.post('/auth/login', { email, password });
|
|
||||||
},
|
|
||||||
deleteAccount() {
|
|
||||||
return api.delete('/auth/profile');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import { api } from './api';
|
import { neoApi } from './neoApi';
|
||||||
|
|
||||||
|
|
||||||
export const favoritesAPI = {
|
export const favoritesAPI = {
|
||||||
// Получить все избранные
|
// Получение всех избранных
|
||||||
getFavorites() {
|
getFavorites() {
|
||||||
return api.get('/favorites');
|
return neoApi.get('/api/v1/favorites');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Добавить в избранное
|
// Добавление в избранное
|
||||||
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) {
|
addFavorite(data: { mediaId: string; mediaType: string; title: string; posterPath?: string }) {
|
||||||
const { mediaId, mediaType, title, posterPath } = data;
|
const { mediaId, mediaType, ...rest } = data;
|
||||||
return api.post(`/favorites/${mediaId}?mediaType=${mediaType}`, { title, posterPath });
|
return neoApi.post(`/api/v1/favorites/${mediaId}?mediaType=${mediaType}`, rest);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Удалить из избранного
|
// Удаление из избранного
|
||||||
removeFavorite(mediaId: string) {
|
removeFavorite(mediaId: string) {
|
||||||
return api.delete(`/favorites/${mediaId}`);
|
return neoApi.delete(`/api/v1/favorites/${mediaId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Проверить есть ли в избранном
|
// Проверка, добавлен ли в избранное
|
||||||
checkFavorite(mediaId: string) {
|
checkFavorite(mediaId: string) {
|
||||||
return api.get(`/favorites/check/${mediaId}`);
|
return neoApi.get(`/api/v1/favorites/check/${mediaId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,32 +29,26 @@ export async function connectToDatabase() {
|
|||||||
return { db, client };
|
return { db, client };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация MongoDB
|
|
||||||
export async function initMongoDB() {
|
export async function initMongoDB() {
|
||||||
try {
|
try {
|
||||||
const { db } = await connectToDatabase();
|
const { db } = await connectToDatabase();
|
||||||
|
|
||||||
// Создаем уникальный индекс для избранного
|
|
||||||
await db.collection('favorites').createIndex(
|
await db.collection('favorites').createIndex(
|
||||||
{ userId: 1, mediaId: 1, mediaType: 1 },
|
{ userId: 1, mediaId: 1, mediaType: 1 },
|
||||||
{ unique: true }
|
{ unique: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('MongoDB initialized successfully');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing MongoDB:', error);
|
console.error('Error initializing MongoDB:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция для сброса и создания индексов
|
|
||||||
export async function resetIndexes() {
|
export async function resetIndexes() {
|
||||||
const { db } = await connectToDatabase();
|
const { db } = await connectToDatabase();
|
||||||
|
|
||||||
// Удаляем все индексы из коллекции favorites
|
|
||||||
await db.collection('favorites').dropIndexes();
|
await db.collection('favorites').dropIndexes();
|
||||||
|
|
||||||
// Создаем новый правильный индекс
|
|
||||||
await db.collection('favorites').createIndex(
|
await db.collection('favorites').createIndex(
|
||||||
{ userId: 1, mediaId: 1, mediaType: 1 },
|
{ userId: 1, mediaId: 1, mediaType: 1 },
|
||||||
{ unique: true }
|
{ unique: true }
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://neomovies-test-api.vercel.app';
|
||||||
|
|
||||||
|
// Создание экземпляра Axios с базовыми настройками
|
||||||
export const neoApi = axios.create({
|
export const neoApi = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
timeout: 30000 // Увеличиваем таймаут до 30 секунд
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Добавляем перехватчики запросов
|
// Перехватчик запросов
|
||||||
neoApi.interceptors.request.use(
|
neoApi.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
|
// Получение токена из localStorage или другого хранилища
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
// Логика для пагинации
|
||||||
if (config.params?.page) {
|
if (config.params?.page) {
|
||||||
const page = parseInt(config.params.page);
|
const page = parseInt(config.params.page);
|
||||||
if (isNaN(page) || page < 1) {
|
if (isNaN(page) || page < 1) {
|
||||||
@@ -27,29 +34,34 @@ neoApi.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Добавляем перехватчики ответов
|
// Перехватчик ответов
|
||||||
neoApi.interceptors.response.use(
|
neoApi.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
if (response.data && response.data.success && response.data.data !== undefined) {
|
||||||
|
response.data = response.data.data;
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('❌ Response Error:', {
|
console.error('❌ Response Error:', {
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
url: error.config?.url,
|
url: error.config?.url,
|
||||||
method: error.config?.method,
|
method: error.config?.method,
|
||||||
message: error.message
|
message: error.message,
|
||||||
|
data: error.response?.data
|
||||||
});
|
});
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Функция для получения URL изображения
|
|
||||||
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
|
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
|
||||||
if (!path) return '/images/placeholder.jpg';
|
if (!path) return '/images/placeholder.jpg';
|
||||||
// Извлекаем только ID изображения из полного пути
|
if (path.startsWith('http')) {
|
||||||
const imageId = path.split('/').pop();
|
return path;
|
||||||
if (!imageId) return '/images/placeholder.jpg';
|
}
|
||||||
return `${API_URL}/images/${size}/${imageId}`;
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Genre {
|
export interface Genre {
|
||||||
@@ -80,10 +92,44 @@ export interface MovieResponse {
|
|||||||
total_results: number;
|
total_results: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TorrentResult {
|
||||||
|
title: string;
|
||||||
|
tracker: string;
|
||||||
|
size: string;
|
||||||
|
seeders: number;
|
||||||
|
peers: number;
|
||||||
|
leechers: number;
|
||||||
|
quality: string;
|
||||||
|
voice?: string[];
|
||||||
|
types?: string[];
|
||||||
|
seasons?: number[];
|
||||||
|
category: string;
|
||||||
|
magnet: string;
|
||||||
|
torrent_link?: string;
|
||||||
|
details?: string;
|
||||||
|
publish_date: string;
|
||||||
|
added_date?: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorrentSearchResponse {
|
||||||
|
query: string;
|
||||||
|
results: TorrentResult[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableSeasonsResponse {
|
||||||
|
title: string;
|
||||||
|
originalTitle: string;
|
||||||
|
year: string;
|
||||||
|
seasons: number[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const searchAPI = {
|
export const searchAPI = {
|
||||||
// Поиск фильмов
|
// Поиск фильмов
|
||||||
searchMovies(query: string, page = 1) {
|
searchMovies(query: string, page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/movies/search', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
||||||
params: {
|
params: {
|
||||||
query,
|
query,
|
||||||
page
|
page
|
||||||
@@ -94,7 +140,7 @@ export const searchAPI = {
|
|||||||
|
|
||||||
// Поиск сериалов
|
// Поиск сериалов
|
||||||
searchTV(query: string, page = 1) {
|
searchTV(query: string, page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/tv/search', {
|
return neoApi.get<MovieResponse>('/api/v1/tv/search', {
|
||||||
params: {
|
params: {
|
||||||
query,
|
query,
|
||||||
page
|
page
|
||||||
@@ -103,46 +149,19 @@ export const searchAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Мультипоиск (фильмы и сериалы)
|
// Мультипоиск (фильмы и сериалы) - новый эндпоинт
|
||||||
async multiSearch(query: string, page = 1) {
|
async multiSearch(query: string, page = 1) {
|
||||||
// Запускаем параллельные запросы к фильмам и сериалам
|
|
||||||
try {
|
try {
|
||||||
const [moviesResponse, tvResponse] = await Promise.all([
|
// Используем новый эндпоинт Go API
|
||||||
this.searchMovies(query, page),
|
const response = await neoApi.get<MovieResponse>('/search/multi', {
|
||||||
this.searchTV(query, page)
|
params: {
|
||||||
]);
|
query,
|
||||||
|
page
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
// Объединяем результаты
|
return response;
|
||||||
const moviesData = moviesResponse.data;
|
|
||||||
const tvData = tvResponse.data;
|
|
||||||
|
|
||||||
// Метаданные для пагинации
|
|
||||||
const totalResults = (moviesData.total_results || 0) + (tvData.total_results || 0);
|
|
||||||
const totalPages = Math.max(moviesData.total_pages || 0, tvData.total_pages || 0);
|
|
||||||
|
|
||||||
// Добавляем информацию о типе контента
|
|
||||||
const moviesWithType = (moviesData.results || []).map(movie => ({
|
|
||||||
...movie,
|
|
||||||
media_type: 'movie'
|
|
||||||
}));
|
|
||||||
|
|
||||||
const tvWithType = (tvData.results || []).map(show => ({
|
|
||||||
...show,
|
|
||||||
media_type: 'tv'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Объединяем и сортируем по популярности
|
|
||||||
const combinedResults = [...moviesWithType, ...tvWithType]
|
|
||||||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
page: parseInt(String(page)),
|
|
||||||
results: combinedResults,
|
|
||||||
total_pages: totalPages,
|
|
||||||
total_results: totalResults
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in multiSearch:', error);
|
console.error('Error in multiSearch:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -153,7 +172,7 @@ export const searchAPI = {
|
|||||||
export const moviesAPI = {
|
export const moviesAPI = {
|
||||||
// Получение популярных фильмов
|
// Получение популярных фильмов
|
||||||
getPopular(page = 1) {
|
getPopular(page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/movies/popular', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/popular', {
|
||||||
params: { page },
|
params: { page },
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -161,7 +180,7 @@ export const moviesAPI = {
|
|||||||
|
|
||||||
// Получение фильмов с высоким рейтингом
|
// Получение фильмов с высоким рейтингом
|
||||||
getTopRated(page = 1) {
|
getTopRated(page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/movies/top_rated', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/top-rated', {
|
||||||
params: { page },
|
params: { page },
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -169,7 +188,15 @@ export const moviesAPI = {
|
|||||||
|
|
||||||
// Получение новинок
|
// Получение новинок
|
||||||
getNowPlaying(page = 1) {
|
getNowPlaying(page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/movies/now_playing', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/now-playing', {
|
||||||
|
params: { page },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение предстоящих фильмов
|
||||||
|
getUpcoming(page = 1) {
|
||||||
|
return neoApi.get<MovieResponse>('/api/v1/movies/upcoming', {
|
||||||
params: { page },
|
params: { page },
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -177,12 +204,12 @@ export const moviesAPI = {
|
|||||||
|
|
||||||
// Получение данных о фильме по его ID
|
// Получение данных о фильме по его ID
|
||||||
getMovie(id: string | number) {
|
getMovie(id: string | number) {
|
||||||
return neoApi.get(`/movies/${id}`, { timeout: 30000 });
|
return neoApi.get(`/api/v1/movies/${id}`, { timeout: 30000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Поиск фильмов
|
// Поиск фильмов
|
||||||
searchMovies(query: string, page = 1) {
|
searchMovies(query: string, page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/movies/search', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
||||||
params: {
|
params: {
|
||||||
query,
|
query,
|
||||||
page
|
page
|
||||||
@@ -191,16 +218,40 @@ export const moviesAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение IMDB ID
|
// Получение IMDB и других external ids
|
||||||
getImdbId(id: string | number) {
|
getExternalIds(id: string | number) {
|
||||||
return neoApi.get(`/movies/${id}/external_ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
|
return neoApi.get(`/api/v1/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tvShowsAPI = {
|
export const tvShowsAPI = {
|
||||||
// Получение популярных сериалов
|
// Получение популярных сериалов
|
||||||
getPopular(page = 1) {
|
getPopular(page = 1) {
|
||||||
return neoApi.get('/tv/popular', {
|
return neoApi.get('/api/v1/tv/popular', {
|
||||||
|
params: { page },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение сериалов с высоким рейтингом
|
||||||
|
getTopRated(page = 1) {
|
||||||
|
return neoApi.get('/api/v1/tv/top-rated', {
|
||||||
|
params: { page },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение сериалов в эфире
|
||||||
|
getOnTheAir(page = 1) {
|
||||||
|
return neoApi.get('/api/v1/tv/on-the-air', {
|
||||||
|
params: { page },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение сериалов, которые выходят сегодня
|
||||||
|
getAiringToday(page = 1) {
|
||||||
|
return neoApi.get('/api/v1/tv/airing-today', {
|
||||||
params: { page },
|
params: { page },
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -208,12 +259,12 @@ export const tvShowsAPI = {
|
|||||||
|
|
||||||
// Получение данных о сериале по его ID
|
// Получение данных о сериале по его ID
|
||||||
getTVShow(id: string | number) {
|
getTVShow(id: string | number) {
|
||||||
return neoApi.get(`/tv/${id}`, { timeout: 30000 });
|
return neoApi.get(`/api/v1/tv/${id}`, { timeout: 30000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Поиск сериалов
|
// Поиск сериалов
|
||||||
searchTVShows(query: string, page = 1) {
|
searchTVShows(query: string, page = 1) {
|
||||||
return neoApi.get('/tv/search', {
|
return neoApi.get('/api/v1/tv/search', {
|
||||||
params: {
|
params: {
|
||||||
query,
|
query,
|
||||||
page
|
page
|
||||||
@@ -222,8 +273,101 @@ export const tvShowsAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение IMDB ID
|
// Получение IMDB и других external ids
|
||||||
getImdbId(id: string | number) {
|
getExternalIds(id: string | number) {
|
||||||
return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
|
return neoApi.get(`/api/v1/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const torrentsAPI = {
|
||||||
|
// Поиск торрентов по IMDB ID
|
||||||
|
searchTorrents(imdbId: string, type: 'movie' | 'tv', options?: {
|
||||||
|
season?: number;
|
||||||
|
quality?: string;
|
||||||
|
minQuality?: string;
|
||||||
|
maxQuality?: string;
|
||||||
|
excludeQualities?: string;
|
||||||
|
hdr?: boolean;
|
||||||
|
hevc?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
groupByQuality?: boolean;
|
||||||
|
groupBySeason?: boolean;
|
||||||
|
}) {
|
||||||
|
const params: any = { type };
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
Object.entries(options).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (key === 'excludeQualities' && Array.isArray(value)) {
|
||||||
|
params[key] = value.join(',');
|
||||||
|
} else {
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return neoApi.get<TorrentSearchResponse>(`/api/v1/torrents/search/${imdbId}`, {
|
||||||
|
params,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение доступных сезонов для сериала
|
||||||
|
getAvailableSeasons(title: string, originalTitle?: string, year?: string) {
|
||||||
|
const params: any = { title };
|
||||||
|
if (originalTitle) params.originalTitle = originalTitle;
|
||||||
|
if (year) params.year = year;
|
||||||
|
|
||||||
|
return neoApi.get<AvailableSeasonsResponse>('/api/v1/torrents/seasons', {
|
||||||
|
params,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Универсальный поиск торрентов по запросу
|
||||||
|
searchByQuery(query: string, type: 'movie' | 'tv' | 'anime' = 'movie', year?: string) {
|
||||||
|
const params: any = { query, type };
|
||||||
|
if (year) params.year = year;
|
||||||
|
|
||||||
|
return neoApi.get<TorrentSearchResponse>('/api/v1/torrents/search', {
|
||||||
|
params,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categoriesAPI = {
|
||||||
|
// Получение всех категорий
|
||||||
|
getCategories() {
|
||||||
|
return neoApi.get<{ categories: Category[] }>('/api/v1/categories');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение категории по ID
|
||||||
|
getCategory(id: number) {
|
||||||
|
return neoApi.get<Category>(`/api/v1/categories/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение фильмов по категории
|
||||||
|
getMoviesByCategory(categoryId: number, page = 1) {
|
||||||
|
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/movies`, {
|
||||||
|
params: { page }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение сериалов по категории
|
||||||
|
getTVShowsByCategory(categoryId: number, page = 1) {
|
||||||
|
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/tv`, {
|
||||||
|
params: { page }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Новый API-клиент для работы с аутентификацией и профилем
|
||||||
|
export const authAPI = {
|
||||||
|
// Новый метод для удаления аккаунта
|
||||||
|
deleteAccount() {
|
||||||
|
return neoApi.delete('/api/v1/profile');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { api } from './api';
|
import { neoApi } from './neoApi';
|
||||||
|
|
||||||
export interface Reaction {
|
export interface Reaction {
|
||||||
_id: string;
|
type: 'like' | 'dislike';
|
||||||
userId: string;
|
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
type: 'fire' | 'nice' | 'think' | 'bore' | 'shit';
|
|
||||||
createdAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reactionsAPI = {
|
export const reactionsAPI = {
|
||||||
// [PUBLIC] Получить счетчики для всех типов реакций
|
// Получение счетчиков реакций
|
||||||
getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record<string, number> }> {
|
getReactionCounts(mediaType: string, mediaId: string) {
|
||||||
return api.get(`/reactions/${mediaType}/${mediaId}/counts`);
|
return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/counts`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// [AUTH] Получить реакцию пользователя для медиа
|
// Получение моей реакции
|
||||||
getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> {
|
getMyReaction(mediaType: string, mediaId: string) {
|
||||||
return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`);
|
return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/my-reaction`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// [AUTH] Установить/обновить/удалить реакцию
|
// Установка реакции
|
||||||
setReaction(mediaType: string, mediaId: string, type: Reaction['type']): Promise<{ data: Reaction }> {
|
setReaction(mediaType: string, mediaId: string, type: 'like' | 'dislike') {
|
||||||
const fullMediaId = `${mediaType}_${mediaId}`;
|
const fullMediaId = `${mediaType}_${mediaId}`;
|
||||||
return api.post('/reactions', { mediaId: fullMediaId, type });
|
return neoApi.post('/api/v1/reactions', { mediaId: fullMediaId, type });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Удаление реакции
|
||||||
|
removeReaction(mediaType: string, mediaId: string) {
|
||||||
|
return neoApi.delete(`/api/v1/reactions/${mediaType}/${mediaId}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -35,7 +35,6 @@ const FavoriteSchema: Schema = new Schema({
|
|||||||
timestamps: true,
|
timestamps: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure a user can't favorite the same item multiple times
|
|
||||||
FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true });
|
FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true });
|
||||||
|
|
||||||
export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema);
|
export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema);
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const userSchema = new Schema<IUser>({
|
|||||||
timestamps: true,
|
timestamps: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Не включаем пароль в запросы по умолчанию
|
|
||||||
userSchema.set('toJSON', {
|
userSchema.set('toJSON', {
|
||||||
transform: function(doc, ret) {
|
transform: function(doc, ret) {
|
||||||
delete ret.password;
|
delete ret.password;
|
||||||
@@ -48,7 +47,6 @@ userSchema.set('toJSON', {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Хэшируем пароль перед сохранением
|
|
||||||
userSchema.pre('save', async function(next) {
|
userSchema.pre('save', async function(next) {
|
||||||
if (!this.isModified('password')) return next();
|
if (!this.isModified('password')) return next();
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ userSchema.pre('save', async function(next) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Метод для проверки пароля
|
|
||||||
userSchema.methods.comparePassword = async function(candidatePassword: string) {
|
userSchema.methods.comparePassword = async function(candidatePassword: string) {
|
||||||
try {
|
try {
|
||||||
return await bcrypt.compare(candidatePassword, this.password);
|
return await bcrypt.compare(candidatePassword, this.password);
|
||||||
|
|||||||
Reference in New Issue
Block a user