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 - это современная веб-платформа построенная с использованием передовых технологий. Проект предлагает удобный интерфейс, быструю навигацию и множество функций для комфортного просмотра информации об фильмах и сералах а также стороние плееры предоставляемые видео-балансерами.
|
||||
|
||||
### ✨ Основные возможности
|
||||
|
||||
- 🎥 Два встроенных видеоплеера на выбор (Alloha, Lumex)
|
||||
- 🔍 Умный поиск по фильмам
|
||||
- 📱 Адаптивный дизайн для всех устройств
|
||||
- 🌙 Темная тема
|
||||
- 👤 Система авторизации и профили пользователей
|
||||
- ❤️ Возможность добавлять фильмы в избранное
|
||||
- ⚡ Быстрая загрузка и оптимизированная производительность
|
||||
- 🎭 **TMDB интеграция** - полная информация о фильмах и сериалах
|
||||
- 🔍 **Умный поиск** - поиск по названию, актерам, жанрам
|
||||
- 🎬 **Встроенные плееры** - просмотр через Alloha и Lumex
|
||||
- 🧲 **Торрент интеграция** - поиск раздач по IMDB ID
|
||||
- ⭐ **Система избранного** - сохраняйте любимые фильмы
|
||||
- 🎨 **Современный UI** - адаптивный дизайн с темной темой
|
||||
- 📱 **Мобильная версия** - оптимизировано для всех устройств
|
||||
- 🔐 **JWT аутентификация** - безопасная авторизация
|
||||
- 📧 **Email верификация** - подтверждение аккаунта
|
||||
|
||||
## 🛠 Технологии
|
||||
|
||||
- **Frontend:**
|
||||
- Next.js 13+ (App Router)
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Styled Components
|
||||
- JWT-based authentication (custom)
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS, Radix UI
|
||||
- **State Management**: Redux Toolkit
|
||||
- **API**: Go API (neomovies-api)
|
||||
- **Database**: MongoDB
|
||||
- **Authentication**: JWT
|
||||
- **Deployment**: Vercel
|
||||
|
||||
- **Backend:**
|
||||
- Node.js + Express (neomovies-api)
|
||||
- MongoDB (native driver)
|
||||
## 📦 Установка
|
||||
|
||||
- **Дополнительно:**
|
||||
- ESLint
|
||||
- Prettier
|
||||
- Git
|
||||
- npm
|
||||
|
||||
## Начало работы
|
||||
|
||||
1. Клонируйте репозиторий:
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://gitlab.com/foxixus/neomovies.git
|
||||
cd neomovies
|
||||
git clone https://github.com/Ernous/neomovies-web.git
|
||||
cd neomovies-web
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
2. **Установите зависимости:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Создайте файл `.env` и добавьте следующие переменные:
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app
|
||||
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
|
||||
NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
||||
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
|
||||
```
|
||||
|
||||
|
||||
4. **Запустите проект:**
|
||||
```bash
|
||||
# Режим разработки
|
||||
@@ -72,33 +55,37 @@ npm start
|
||||
|
||||
## 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/
|
||||
│ ├── app/ # App Router pages
|
||||
│ ├── components/ # React компоненты
|
||||
│ ├── hooks/ # React хуки
|
||||
│ ├── lib/ # Утилиты и API
|
||||
│ ├── models/ # MongoDB модели
|
||||
│ ├── types/ # TypeScript типы
|
||||
│ └── styles/ # Глобальные стили
|
||||
├── public/ # Статические файлы
|
||||
└── package.json
|
||||
@@ -108,6 +95,7 @@ neomovies/
|
||||
## 👥 Авторы
|
||||
|
||||
- **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
|
||||
- [Vercel](https://vercel.com/) за хостинг API
|
||||
- [Vercel](https://vercel.com/) за хостинг
|
||||
|
||||
## 📞 Контакты
|
||||
|
||||
|
||||
@@ -28,10 +28,16 @@ const nextConfig = {
|
||||
port: '3000',
|
||||
pathname: '/images/**',
|
||||
},
|
||||
// Продакшен на Vercel
|
||||
// Наш API прокси для изображений
|
||||
{
|
||||
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/**',
|
||||
},
|
||||
{
|
||||
|
||||
839
package-lock.json
generated
839
package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "neo-movies-web",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -20,7 +23,9 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -28,15 +33,18 @@
|
||||
"mongodb": "^6.12.0",
|
||||
"mongoose": "^8.9.2",
|
||||
"next": "15.1.2",
|
||||
"next-seo": "^6.8.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"resend": "^4.0.1",
|
||||
"styled-components": "^6.1.13",
|
||||
"tailwind": "^4.0.0"
|
||||
"tailwind": "^4.0.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -45,6 +53,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
@@ -72,6 +81,13 @@
|
||||
"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": {
|
||||
"version": "1.4.3",
|
||||
"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_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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1056,6 +1110,562 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
|
||||
@@ -1290,7 +1900,7 @@
|
||||
"version": "19.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@@ -2128,6 +2738,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -2762,6 +3384,18 @@
|
||||
"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": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
@@ -2774,6 +3408,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -3260,6 +3903,12 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -4652,6 +5301,15 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"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": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
@@ -7223,6 +7927,15 @@
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -8416,6 +9198,16 @@
|
||||
"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": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
|
||||
@@ -8919,6 +9711,49 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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",
|
||||
"@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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { neoApi } from '@/lib/neoApi';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
@@ -92,12 +92,12 @@ export default function AdminLoginClient() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login', {
|
||||
const response = await neoApi.post('/api/v1/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const { token, user } = response.data;
|
||||
const { token, user } = response.data.data || response.data;
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
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 { useParams, useRouter } from 'next/navigation';
|
||||
import { categoriesAPI, Movie, TVShow } from '@/lib/api';
|
||||
import { categoriesAPI, Movie } from '@/lib/neoApi';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -60,10 +60,10 @@ function CategoryPage() {
|
||||
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
||||
const hasTvShows = response.data.results.length > 0;
|
||||
if (page === 1) setTvShowsAvailable(hasTvShows);
|
||||
const transformedShows = response.data.results.map((show: TVShow) => ({
|
||||
const transformedShows = response.data.results.map((show: any) => ({
|
||||
...show,
|
||||
title: show.name,
|
||||
release_date: show.first_air_date,
|
||||
title: show.name || show.title,
|
||||
release_date: show.first_air_date || show.release_date,
|
||||
}));
|
||||
setItems(transformedShows);
|
||||
setTotalPages(response.data.total_pages);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { categoriesAPI } from '@/lib/api';
|
||||
import { Category } from '@/lib/api';
|
||||
import { categoriesAPI, Category } from '@/lib/neoApi';
|
||||
import CategoryCard from '@/components/CategoryCard';
|
||||
|
||||
interface CategoryWithBackground extends Category {
|
||||
@@ -14,14 +13,12 @@ function CategoriesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка категорий и фоновых изображений для них
|
||||
useEffect(() => {
|
||||
async function fetchCategoriesAndBackgrounds() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Получаем список категорий
|
||||
const categoriesResponse = await categoriesAPI.getCategories();
|
||||
|
||||
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
|
||||
@@ -30,14 +27,11 @@ function CategoriesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем фоновые изображения для каждой категории
|
||||
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
||||
categoriesResponse.data.categories.map(async (category: Category) => {
|
||||
try {
|
||||
// Сначала пробуем получить фильм для фона
|
||||
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
|
||||
|
||||
// Проверяем, есть ли фильмы в данной категории
|
||||
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
|
||||
const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
|
||||
moviesResponse.data.results[0].poster_path;
|
||||
@@ -47,7 +41,6 @@ function CategoriesPage() {
|
||||
backgroundUrl
|
||||
};
|
||||
} else {
|
||||
// Если фильмов нет, пробуем получить сериалы
|
||||
const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1);
|
||||
|
||||
if (tvResponse.data.results && tvResponse.data.results.length > 0) {
|
||||
@@ -61,14 +54,13 @@ function CategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Если ни фильмов, ни сериалов не найдено
|
||||
return {
|
||||
...category,
|
||||
backgroundUrl: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching background for category ${category.id}:`, error);
|
||||
return category; // Возвращаем категорию без фона в случае ошибки
|
||||
return category;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -49,3 +49,11 @@ body {
|
||||
[data-nextjs-toast-wrapper] {
|
||||
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 { moviesAPI } 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 TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
@@ -20,23 +20,25 @@ interface MovieContentProps {
|
||||
|
||||
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
||||
const [movie] = useState<MovieDetails>(initialMovie);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
const { data } = await moviesAPI.getMovie(movieId);
|
||||
const data = await moviesAPI.getExternalIds(movieId);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching IMDb ID:', err);
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
fetchImdbId();
|
||||
fetchExternalIds();
|
||||
}, [movieId]);
|
||||
|
||||
const showControls = () => {
|
||||
@@ -182,6 +184,9 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="movie"
|
||||
title={movie.title}
|
||||
originalTitle={movie.original_title}
|
||||
year={movie.release_date?.split('-')[0]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import MovieContent from './MovieContent';
|
||||
import type { MovieDetails } from '@/lib/api';
|
||||
import type { MovieDetails } from '@/lib/neoApi';
|
||||
|
||||
interface MoviePageProps {
|
||||
movieId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { moviesAPI } from '@/lib/api';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import MoviePage from '@/app/movie/[id]/MoviePage';
|
||||
|
||||
interface PageProps {
|
||||
@@ -8,19 +8,13 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
// Генерация метаданных для страницы
|
||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||
const { params } = await props;
|
||||
// В Next.js 14, нужно сначала получить данные фильма,
|
||||
// а затем использовать их для метаданных
|
||||
try {
|
||||
// Получаем id для использования в запросе
|
||||
const movieId = params.id;
|
||||
|
||||
// Запрашиваем данные фильма
|
||||
const { data: movie } = await moviesAPI.getMovie(movieId);
|
||||
|
||||
// Создаем метаданные на основе полученных данных
|
||||
return {
|
||||
title: `${movie.title} - NeoMovies`,
|
||||
description: movie.overview,
|
||||
@@ -33,7 +27,6 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
|
||||
}
|
||||
}
|
||||
|
||||
// Получение данных для страницы
|
||||
async function getData(id: string) {
|
||||
try {
|
||||
const { data: movie } = await moviesAPI.getMovie(id);
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function HomePage() {
|
||||
Популярные
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('now_playing')}
|
||||
onClick={() => setActiveTab('now-playing')}
|
||||
className={`${
|
||||
activeTab === 'now_playing'
|
||||
activeTab === 'now-playing'
|
||||
? '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`}
|
||||
@@ -59,15 +59,25 @@ export default function HomePage() {
|
||||
Новинки
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('top_rated')}
|
||||
onClick={() => setActiveTab('top-rated')}
|
||||
className={`${
|
||||
activeTab === 'top_rated'
|
||||
activeTab === 'top-rated'
|
||||
? '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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
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';
|
||||
|
||||
export default function SearchClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
|
||||
const [results, setResults] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
moviesAPI.searchMovies(currentQuery),
|
||||
tvAPI.searchShows(currentQuery),
|
||||
])
|
||||
.then(([movieResults, tvResults]) => {
|
||||
const combined = [
|
||||
...(movieResults.data.results || []),
|
||||
...(tvResults.data.results || []),
|
||||
];
|
||||
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
|
||||
searchAPI.multiSearch(currentQuery)
|
||||
.then((response) => {
|
||||
setResults(response.data.results || []);
|
||||
})
|
||||
.catch((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">
|
||||
{results.map((item) => (
|
||||
<MovieCard
|
||||
key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`}
|
||||
key={`${item.id}-${item.media_type || 'movie'}`}
|
||||
movie={item}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { tvAPI } from '@/lib/api';
|
||||
import { tvShowsAPI } 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 TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
@@ -20,29 +20,28 @@ interface TVContentProps {
|
||||
|
||||
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||
const [show] = useState<TVShowDetails>(initialShow);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
// Используем dedicated эндпоинт для получения IMDb ID
|
||||
const { data } = await tvAPI.getImdbId(showId);
|
||||
const data = await tvShowsAPI.getExternalIds(showId);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching IMDb ID:', err);
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
|
||||
if (initialShow.external_ids?.imdb_id) {
|
||||
setImdbId(initialShow.external_ids.imdb_id);
|
||||
} else {
|
||||
fetchImdbId();
|
||||
fetchExternalIds();
|
||||
}
|
||||
}, [showId, initialShow.external_ids]);
|
||||
|
||||
@@ -185,7 +184,9 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="tv"
|
||||
totalSeasons={show.number_of_seasons}
|
||||
title={show.name}
|
||||
originalTitle={show.original_name}
|
||||
year={show.first_air_date?.split('-')[0]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import TVContent from '@/app/tv/[id]/TVContent';
|
||||
import type { TVShowDetails } from '@/lib/api';
|
||||
import type { TVShowDetails } from '@/lib/neoApi';
|
||||
|
||||
interface TVPageProps {
|
||||
showId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { tvAPI } from '@/lib/api';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
import TVPage from '@/app/tv/[id]/TVPage';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -10,12 +10,11 @@ interface PageProps {
|
||||
};
|
||||
}
|
||||
|
||||
// Генерация метаданных для страницы
|
||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||
const { params } = await props;
|
||||
try {
|
||||
const showId = params.id;
|
||||
const { data: show } = await tvAPI.getShow(showId);
|
||||
const { data: show } = await tvShowsAPI.getTVShow(showId);
|
||||
|
||||
return {
|
||||
title: `${show.name} - NeoMovies`,
|
||||
@@ -29,10 +28,9 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
|
||||
}
|
||||
}
|
||||
|
||||
// Получение данных для страницы
|
||||
async function getData(id: string) {
|
||||
try {
|
||||
const { data: show } = await tvAPI.getShow(id);
|
||||
const { data: show } = await tvShowsAPI.getTVShow(id);
|
||||
return { id, show };
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch TV show');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Category } from '@/lib/api';
|
||||
import { Category } from '@/lib/neoApi';
|
||||
|
||||
interface CategoryCardProps {
|
||||
category: Category;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { moviesAPI, api } from '@/lib/api';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
interface MoviePlayerProps {
|
||||
@@ -22,7 +22,10 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
if (imdbId) return;
|
||||
if (imdbId) {
|
||||
setResolvedImdb(imdbId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -40,42 +43,43 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
||||
}, [id, imdbId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPlayer = async () => {
|
||||
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;
|
||||
if (!src && typeof data === 'string') {
|
||||
const match = data.match(/<iframe[^>]*src="([^"]+)"/i);
|
||||
if (match && match[1]) src = match[1];
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!API_BASE_URL) {
|
||||
setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.');
|
||||
return;
|
||||
}
|
||||
if (!src) throw new Error('Invalid response format');
|
||||
setIframeSrc(src);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Не удалось загрузить плеер. Попробуйте позже.');
|
||||
} finally {
|
||||
|
||||
const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex';
|
||||
|
||||
// Формируем URL, где imdbId является частью пути
|
||||
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
|
||||
|
||||
setIframeSrc(newIframeSrc);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadPlayer();
|
||||
}, [resolvedImdb, isInitialized, settings.defaultPlayer]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(null);
|
||||
if (!resolvedImdb) {
|
||||
// Re-fetch IMDb ID
|
||||
const event = new Event('fetchImdb');
|
||||
window.dispatchEvent(event);
|
||||
} else {
|
||||
// Re-load player
|
||||
const event = new Event('loadPlayer');
|
||||
window.dispatchEvent(event);
|
||||
setIframeSrc(null);
|
||||
setLoading(true);
|
||||
|
||||
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';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, AlertTriangle, Copy, Check } from 'lucide-react';
|
||||
|
||||
interface Torrent {
|
||||
magnet: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
quality?: string;
|
||||
seeders?: number;
|
||||
size_gb?: number;
|
||||
}
|
||||
|
||||
interface GroupedTorrents {
|
||||
[quality: string]: Torrent[];
|
||||
}
|
||||
|
||||
interface SeasonGroupedTorrents {
|
||||
[season: string]: GroupedTorrents;
|
||||
}
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react';
|
||||
import { torrentsAPI, type TorrentResult } from '@/lib/neoApi';
|
||||
|
||||
interface TorrentSelectorProps {
|
||||
imdbId: string | null;
|
||||
type: 'movie' | 'tv';
|
||||
totalSeasons?: number;
|
||||
title?: string;
|
||||
originalTitle?: string;
|
||||
year?: string;
|
||||
}
|
||||
|
||||
export default function TorrentSelector({ imdbId, type, totalSeasons }: TorrentSelectorProps) {
|
||||
const [torrents, setTorrents] = useState<Torrent[] | null>(null);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(type === 'movie' ? 1 : null);
|
||||
const [selectedMagnet, setSelectedMagnet] = useState<string | null>(null);
|
||||
interface ParsedTorrent extends TorrentResult {
|
||||
quality?: string;
|
||||
season?: number;
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [copiedMagnet, setCopiedMagnet] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Для TV показов автоматически выбираем первый сезон
|
||||
useEffect(() => {
|
||||
if (type === 'tv' && totalSeasons && totalSeasons > 0 && !selectedSeason) {
|
||||
setSelectedSeason(1);
|
||||
const parseQuality = (title: string): string => {
|
||||
const qualityRegex = /(2160p|4K|UHD|1080p|FHD|720p|HD|480p|SD|CAMRip|TS|TC|DVDRip|BDRip|WEBRip|HDTV)/i;
|
||||
const match = title.match(qualityRegex);
|
||||
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(() => {
|
||||
if (!imdbId) return;
|
||||
|
||||
// Для фильмов загружаем сразу
|
||||
if (type === 'movie') {
|
||||
fetchTorrents();
|
||||
}
|
||||
// Для TV показов загружаем только когда выбран сезон
|
||||
else if (type === 'tv' && selectedSeason) {
|
||||
fetchTorrents();
|
||||
}
|
||||
}, [imdbId, type, selectedSeason]);
|
||||
}, [imdbId, type]);
|
||||
|
||||
const fetchTorrents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedMagnet(null);
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!apiUrl) {
|
||||
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) {
|
||||
const response = await torrentsAPI.searchTorrents(imdbId!, type);
|
||||
if (response.data.total === 0) {
|
||||
setError('Торренты не найдены.');
|
||||
} 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) {
|
||||
console.error(err);
|
||||
setError('Не удалось загрузить список торрентов.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQualitySelect = (torrent: Torrent) => {
|
||||
setSelectedMagnet(torrent.magnet);
|
||||
setIsCopied(false);
|
||||
const filteredTorrents = useMemo(() => {
|
||||
if (!torrents) return [];
|
||||
|
||||
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 = () => {
|
||||
if (!selectedMagnet) return;
|
||||
navigator.clipboard.writeText(selectedMagnet);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
const handleDownload = (magnet: string) => {
|
||||
window.open(magnet, '_blank');
|
||||
};
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-center p-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Загрузка торрентов...</span>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Загрузка торрентов...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-100 p-3 text-sm text-red-700">
|
||||
<AlertTriangle size={20} />
|
||||
<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} className="text-red-600 dark:text-red-500" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!torrents) return null;
|
||||
|
||||
const renderTorrentButtons = (list: Torrent[]) => {
|
||||
if (!list?.length) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Торрентов для выбранного сезона нет.
|
||||
</p>
|
||||
);
|
||||
if (!torrents || torrents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list.map(torrent => {
|
||||
const size = torrent.size_gb;
|
||||
const label = torrent.title || torrent.name || 'Раздача';
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full sm:w-auto" size="lg">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Скачать ({torrents.length} {torrents.length === 1 ? 'раздача' : torrents.length < 5 ? 'раздачи' : 'раздач'})
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-zinc-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-gray-900 dark:text-gray-50">
|
||||
Выберите раздачу для скачивания
|
||||
</DialogTitle>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Найдено {filteredTorrents.length} из {torrents.length} раздач
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{availableQualities.length > 0 && (
|
||||
<div>
|
||||
<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={torrent.magnet}
|
||||
asChild
|
||||
onClick={() => handleQualitySelect(torrent)}
|
||||
variant="outline"
|
||||
className="w-full items-center text-left px-3 py-2"
|
||||
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'}
|
||||
>
|
||||
<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>
|
||||
{quality} ({count})
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
};
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{type === 'tv' && totalSeasons && totalSeasons > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Сезоны</h3>
|
||||
{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">
|
||||
{Array.from({ length: totalSeasons }, (_, i) => i + 1).map(season => (
|
||||
<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}
|
||||
onClick={() => {setSelectedSeason(season); setSelectedMagnet(null);}}
|
||||
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}
|
||||
Сезон {season} ({count})
|
||||
</Button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSeason && torrents && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Раздачи</h3>
|
||||
<div className="space-y-2">
|
||||
{renderTorrentButtons(torrents)}
|
||||
</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} />
|
||||
))
|
||||
)}
|
||||
|
||||
{selectedMagnet && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Magnet-ссылка</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md border bg-secondary/50 px-3 py-2 text-sm">
|
||||
{selectedMagnet}
|
||||
</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>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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 { useState } from 'react';
|
||||
import { authAPI } from '../lib/authApi';
|
||||
import { api } from '../lib/api';
|
||||
import { neoApi } from '../lib/neoApi';
|
||||
|
||||
interface PendingRegistration {
|
||||
email: string;
|
||||
@@ -16,35 +16,36 @@ export function useAuth() {
|
||||
const [pending, setPending] = useState<PendingRegistration | null>(null);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const { data } = await authAPI.login(email, password);
|
||||
try {
|
||||
const response = await authAPI.login(email, password);
|
||||
const data = response.data.data || response.data;
|
||||
if (data?.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
|
||||
// Extract name/email either from API response or JWT payload
|
||||
let name: string | undefined = undefined;
|
||||
let email: string | undefined = undefined;
|
||||
let emailVal: string | undefined = undefined;
|
||||
try {
|
||||
const payload = JSON.parse(atob(data.token.split('.')[1]));
|
||||
name = payload.name || payload.username || payload.userName || payload.sub || undefined;
|
||||
email = payload.email || undefined;
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
emailVal = payload.email || undefined;
|
||||
} catch {}
|
||||
if (!name) name = data.user?.name || data.name || data.userName;
|
||||
if (!email) email = data.user?.email || data.email;
|
||||
|
||||
if (!emailVal) emailVal = data.user?.email || data.email;
|
||||
if (name) localStorage.setItem('userName', name);
|
||||
if (email) localStorage.setItem('userEmail', email);
|
||||
|
||||
if (emailVal) localStorage.setItem('userEmail', emailVal);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('auth-changed'));
|
||||
}
|
||||
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
|
||||
neoApi.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
|
||||
router.push('/');
|
||||
} else {
|
||||
throw new Error(data?.error || 'Login failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401 || err?.response?.status === 400) {
|
||||
throw new Error('Неверный логин или пароль');
|
||||
}
|
||||
throw new Error(err?.message || 'Произошла ошибка');
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
@@ -66,14 +67,11 @@ export function useAuth() {
|
||||
setPending(pendingData);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingData) {
|
||||
throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.');
|
||||
}
|
||||
|
||||
await authAPI.verify(pendingData.email, code);
|
||||
await login(pendingData.email, pendingData.password);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('pendingVerification');
|
||||
}
|
||||
@@ -83,7 +81,7 @@ export function useAuth() {
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
delete neoApi.defaults.headers.common['Authorization'];
|
||||
localStorage.removeItem('userName');
|
||||
localStorage.removeItem('userEmail');
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { moviesAPI } 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 {
|
||||
initialPage?: number;
|
||||
@@ -26,12 +26,15 @@ export function useMovies({ initialPage = 1, category = 'popular' }: UseMoviesPr
|
||||
let response: { data: MovieResponse };
|
||||
|
||||
switch (movieCategory) {
|
||||
case 'top_rated':
|
||||
case 'top-rated':
|
||||
response = await moviesAPI.getTopRated(pageNum);
|
||||
break;
|
||||
case 'now_playing':
|
||||
case 'now-playing':
|
||||
response = await moviesAPI.getNowPlaying(pageNum);
|
||||
break;
|
||||
case 'upcoming':
|
||||
response = await moviesAPI.getUpcoming(pageNum);
|
||||
break;
|
||||
case 'popular':
|
||||
default:
|
||||
response = await moviesAPI.getPopular(pageNum);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { moviesAPI } from '@/lib/api';
|
||||
import type { Movie } from '@/lib/api';
|
||||
import { searchAPI } from '@/lib/neoApi';
|
||||
import type { Movie } from '@/lib/neoApi';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useSearch() {
|
||||
setCurrentQuery(query);
|
||||
setCurrentPage(1);
|
||||
|
||||
const response = await moviesAPI.searchMovies(query, 1);
|
||||
const response = await searchAPI.multiSearch(query, 1);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
|
||||
if (filteredMovies.length === 0) {
|
||||
@@ -74,7 +74,7 @@ export function useSearch() {
|
||||
setLoading(true);
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const response = await moviesAPI.searchMovies(currentQuery, nextPage);
|
||||
const response = await searchAPI.multiSearch(currentQuery, nextPage);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
|
||||
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) => {
|
||||
try {
|
||||
// Сначала проверяем, верифицирован ли аккаунт
|
||||
const verificationCheck = await fetch('/api/auth/check-verification', {
|
||||
const verificationCheck = await fetch('/api/v1/auth/check-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
@@ -27,8 +26,7 @@ export function useUser() {
|
||||
const { isVerified } = await verificationCheck.json();
|
||||
|
||||
if (!isVerified) {
|
||||
// Если аккаунт не верифицирован, отправляем новый код и переходим к верификации
|
||||
const verificationResponse = await fetch('/api/auth/verify', {
|
||||
const verificationResponse = await fetch('/api/v1/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
@@ -43,15 +41,28 @@ export function useUser() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Если аккаунт верифицирован, выполняем вход
|
||||
const result = await signIn('credentials', {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
if (!loginResponse.ok) {
|
||||
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('/');
|
||||
@@ -62,7 +73,7 @@ export function useUser() {
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
@@ -73,8 +84,7 @@ export function useUser() {
|
||||
throw new Error(data.error || 'Ошибка при регистрации');
|
||||
}
|
||||
|
||||
// Отправляем код подтверждения
|
||||
const verificationResponse = await fetch('/api/auth/verify', {
|
||||
const verificationResponse = await fetch('/api/v1/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
@@ -98,7 +108,7 @@ export function useUser() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
const response = await fetch('/api/v1/auth/verify', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -112,15 +122,31 @@ export function useUser() {
|
||||
throw new Error(data.error || 'Неверный код подтверждения');
|
||||
}
|
||||
|
||||
// После успешной верификации выполняем вход
|
||||
const result = await signIn('credentials', {
|
||||
redirect: false,
|
||||
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: pendingRegistration.email,
|
||||
password: pendingRegistration.password,
|
||||
password: pendingRegistration.password
|
||||
})
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
if (!loginResponse.ok) {
|
||||
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);
|
||||
@@ -132,7 +158,15 @@ export function useUser() {
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
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 = {
|
||||
register(data: { email: string; password: string; name?: string }) {
|
||||
return api.post('/auth/register', data);
|
||||
},
|
||||
resendCode(email: string) {
|
||||
return api.post('/auth/resend-code', { email });
|
||||
},
|
||||
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');
|
||||
}
|
||||
register: (data: any) => neoApi.post('/api/v1/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 }),
|
||||
checkVerification: (email: string) => neoApi.post('/api/v1/auth/check-verification', { email }),
|
||||
login: (email: string, password: string) => neoApi.post('/api/v1/auth/login', { email, password }),
|
||||
deleteAccount: () => neoApi.delete('/api/v1/auth/profile'),
|
||||
};
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { api } from './api';
|
||||
|
||||
import { neoApi } from './neoApi';
|
||||
|
||||
export const favoritesAPI = {
|
||||
// Получить все избранные
|
||||
// Получение всех избранных
|
||||
getFavorites() {
|
||||
return api.get('/favorites');
|
||||
return neoApi.get('/api/v1/favorites');
|
||||
},
|
||||
|
||||
// Добавить в избранное
|
||||
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) {
|
||||
const { mediaId, mediaType, title, posterPath } = data;
|
||||
return api.post(`/favorites/${mediaId}?mediaType=${mediaType}`, { title, posterPath });
|
||||
// Добавление в избранное
|
||||
addFavorite(data: { mediaId: string; mediaType: string; title: string; posterPath?: string }) {
|
||||
const { mediaId, mediaType, ...rest } = data;
|
||||
return neoApi.post(`/api/v1/favorites/${mediaId}?mediaType=${mediaType}`, rest);
|
||||
},
|
||||
|
||||
// Удалить из избранного
|
||||
// Удаление из избранного
|
||||
removeFavorite(mediaId: string) {
|
||||
return api.delete(`/favorites/${mediaId}`);
|
||||
return neoApi.delete(`/api/v1/favorites/${mediaId}`);
|
||||
},
|
||||
|
||||
// Проверить есть ли в избранном
|
||||
// Проверка, добавлен ли в избранное
|
||||
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 };
|
||||
}
|
||||
|
||||
// Инициализация MongoDB
|
||||
export async function initMongoDB() {
|
||||
try {
|
||||
const { db } = await connectToDatabase();
|
||||
|
||||
// Создаем уникальный индекс для избранного
|
||||
await db.collection('favorites').createIndex(
|
||||
{ userId: 1, mediaId: 1, mediaType: 1 },
|
||||
{ unique: true }
|
||||
);
|
||||
|
||||
console.log('MongoDB initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing MongoDB:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для сброса и создания индексов
|
||||
export async function resetIndexes() {
|
||||
const { db } = await connectToDatabase();
|
||||
|
||||
// Удаляем все индексы из коллекции favorites
|
||||
await db.collection('favorites').dropIndexes();
|
||||
|
||||
// Создаем новый правильный индекс
|
||||
await db.collection('favorites').createIndex(
|
||||
{ userId: 1, mediaId: 1, mediaType: 1 },
|
||||
{ unique: true }
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
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({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000 // Увеличиваем таймаут до 30 секунд
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// Добавляем перехватчики запросов
|
||||
// Перехватчик запросов
|
||||
neoApi.interceptors.request.use(
|
||||
(config) => {
|
||||
// Получение токена из localStorage или другого хранилища
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Логика для пагинации
|
||||
if (config.params?.page) {
|
||||
const page = parseInt(config.params.page);
|
||||
if (isNaN(page) || page < 1) {
|
||||
@@ -27,29 +34,34 @@ neoApi.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Добавляем перехватчики ответов
|
||||
// Перехватчик ответов
|
||||
neoApi.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data && response.data.success && response.data.data !== undefined) {
|
||||
response.data = response.data.data;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Response Error:', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
message: error.message
|
||||
message: error.message,
|
||||
data: error.response?.data
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Функция для получения URL изображения
|
||||
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
|
||||
if (!path) return '/images/placeholder.jpg';
|
||||
// Извлекаем только ID изображения из полного пути
|
||||
const imageId = path.split('/').pop();
|
||||
if (!imageId) return '/images/placeholder.jpg';
|
||||
return `${API_URL}/images/${size}/${imageId}`;
|
||||
if (path.startsWith('http')) {
|
||||
return path;
|
||||
}
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
|
||||
};
|
||||
|
||||
export interface Genre {
|
||||
@@ -80,10 +92,44 @@ export interface MovieResponse {
|
||||
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 = {
|
||||
// Поиск фильмов
|
||||
searchMovies(query: string, page = 1) {
|
||||
return neoApi.get<MovieResponse>('/movies/search', {
|
||||
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
||||
params: {
|
||||
query,
|
||||
page
|
||||
@@ -94,7 +140,7 @@ export const searchAPI = {
|
||||
|
||||
// Поиск сериалов
|
||||
searchTV(query: string, page = 1) {
|
||||
return neoApi.get<MovieResponse>('/tv/search', {
|
||||
return neoApi.get<MovieResponse>('/api/v1/tv/search', {
|
||||
params: {
|
||||
query,
|
||||
page
|
||||
@@ -103,46 +149,19 @@ export const searchAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
// Мультипоиск (фильмы и сериалы)
|
||||
// Мультипоиск (фильмы и сериалы) - новый эндпоинт
|
||||
async multiSearch(query: string, page = 1) {
|
||||
// Запускаем параллельные запросы к фильмам и сериалам
|
||||
try {
|
||||
const [moviesResponse, tvResponse] = await Promise.all([
|
||||
this.searchMovies(query, page),
|
||||
this.searchTV(query, page)
|
||||
]);
|
||||
// Используем новый эндпоинт Go API
|
||||
const response = await neoApi.get<MovieResponse>('/search/multi', {
|
||||
params: {
|
||||
query,
|
||||
page
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// Объединяем результаты
|
||||
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
|
||||
}
|
||||
};
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error in multiSearch:', error);
|
||||
throw error;
|
||||
@@ -153,7 +172,7 @@ export const searchAPI = {
|
||||
export const moviesAPI = {
|
||||
// Получение популярных фильмов
|
||||
getPopular(page = 1) {
|
||||
return neoApi.get<MovieResponse>('/movies/popular', {
|
||||
return neoApi.get<MovieResponse>('/api/v1/movies/popular', {
|
||||
params: { page },
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -161,7 +180,7 @@ export const moviesAPI = {
|
||||
|
||||
// Получение фильмов с высоким рейтингом
|
||||
getTopRated(page = 1) {
|
||||
return neoApi.get<MovieResponse>('/movies/top_rated', {
|
||||
return neoApi.get<MovieResponse>('/api/v1/movies/top-rated', {
|
||||
params: { page },
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -169,7 +188,15 @@ export const moviesAPI = {
|
||||
|
||||
// Получение новинок
|
||||
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 },
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -177,12 +204,12 @@ export const moviesAPI = {
|
||||
|
||||
// Получение данных о фильме по его ID
|
||||
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) {
|
||||
return neoApi.get<MovieResponse>('/movies/search', {
|
||||
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
||||
params: {
|
||||
query,
|
||||
page
|
||||
@@ -191,16 +218,40 @@ export const moviesAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
// Получение IMDB ID
|
||||
getImdbId(id: string | number) {
|
||||
return neoApi.get(`/movies/${id}/external_ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
|
||||
// Получение IMDB и других external ids
|
||||
getExternalIds(id: string | number) {
|
||||
return neoApi.get(`/api/v1/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data);
|
||||
}
|
||||
};
|
||||
|
||||
export const tvShowsAPI = {
|
||||
// Получение популярных сериалов
|
||||
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 },
|
||||
timeout: 30000
|
||||
});
|
||||
@@ -208,12 +259,12 @@ export const tvShowsAPI = {
|
||||
|
||||
// Получение данных о сериале по его ID
|
||||
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) {
|
||||
return neoApi.get('/tv/search', {
|
||||
return neoApi.get('/api/v1/tv/search', {
|
||||
params: {
|
||||
query,
|
||||
page
|
||||
@@ -222,8 +273,101 @@ export const tvShowsAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
// Получение IMDB ID
|
||||
getImdbId(id: string | number) {
|
||||
return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
|
||||
// Получение IMDB и других external ids
|
||||
getExternalIds(id: string | number) {
|
||||
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 {
|
||||
_id: string;
|
||||
userId: string;
|
||||
type: 'like' | 'dislike';
|
||||
mediaId: string;
|
||||
mediaType: 'movie' | 'tv';
|
||||
type: 'fire' | 'nice' | 'think' | 'bore' | 'shit';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const reactionsAPI = {
|
||||
// [PUBLIC] Получить счетчики для всех типов реакций
|
||||
getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record<string, number> }> {
|
||||
return api.get(`/reactions/${mediaType}/${mediaId}/counts`);
|
||||
// Получение счетчиков реакций
|
||||
getReactionCounts(mediaType: string, mediaId: string) {
|
||||
return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/counts`);
|
||||
},
|
||||
|
||||
// [AUTH] Получить реакцию пользователя для медиа
|
||||
getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> {
|
||||
return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`);
|
||||
// Получение моей реакции
|
||||
getMyReaction(mediaType: string, mediaId: string) {
|
||||
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}`;
|
||||
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,
|
||||
});
|
||||
|
||||
// Ensure a user can't favorite the same item multiple times
|
||||
FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema);
|
||||
|
||||
@@ -40,7 +40,6 @@ const userSchema = new Schema<IUser>({
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// Не включаем пароль в запросы по умолчанию
|
||||
userSchema.set('toJSON', {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
@@ -48,7 +47,6 @@ userSchema.set('toJSON', {
|
||||
}
|
||||
});
|
||||
|
||||
// Хэшируем пароль перед сохранением
|
||||
userSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) return next();
|
||||
|
||||
@@ -61,7 +59,6 @@ userSchema.pre('save', async function(next) {
|
||||
}
|
||||
});
|
||||
|
||||
// Метод для проверки пароля
|
||||
userSchema.methods.comparePassword = async function(candidatePassword: string) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
|
||||
Reference in New Issue
Block a user