Измения в АПИ

This commit is contained in:
2025-08-07 18:33:28 +00:00
parent b2db578f5f
commit a1f1deea13
45 changed files with 1955 additions and 1119 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# API URL для нового Go API
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
# Для локальной разработки используйте:
# NEXT_PUBLIC_API_URL=http://localhost:3000

View File

@@ -1,64 +1,47 @@
# 🎬 Neo Movies # NeoMovies Web 🎬
<div align="center"> > Современный веб-интерфейс для поиска и просмотра фильмов и сериалов
<img src="public/logo.png" alt="Neo Movies Logo" width="200"/>
<p><strong>Современный онлайн-сервис с удобным интерфейсом</strong></p>
</div>
## 📋 О проекте ## 🚀 Особенности
Neo Movies - это современная веб-платформа построенная с использованием передовых технологий. Проект предлагает удобный интерфейс, быструю навигацию и множество функций для комфортного просмотра информации об фильмах и сералах а также стороние плееры предоставляемые видео-балансерами. - 🎭 **TMDB интеграция** - полная информация о фильмах и сериалах
- 🔍 **Умный поиск** - поиск по названию, актерам, жанрам
### ✨ Основные возможности - 🎬 **Встроенные плееры** - просмотр через Alloha и Lumex
- 🧲 **Торрент интеграция** - поиск раздач по IMDB ID
- 🎥 Два встроенных видеоплеера на выбор (Alloha, Lumex) - **Система избранного** - сохраняйте любимые фильмы
- 🔍 Умный поиск по фильмам - 🎨 **Современный UI** - адаптивный дизайн с темной темой
- 📱 Адаптивный дизайн для всех устройств - 📱 **Мобильная версия** - оптимизировано для всех устройств
- 🌙 Темная тема - 🔐 **JWT аутентификация** - безопасная авторизация
- 👤 Система авторизации и профили пользователей - 📧 **Email верификация** - подтверждение аккаунта
- ❤️ Возможность добавлять фильмы в избранное
- ⚡ Быстрая загрузка и оптимизированная производительность
## 🛠 Технологии ## 🛠 Технологии
- **Frontend:** - **Frontend**: Next.js 15, React 19, TypeScript
- Next.js 13+ (App Router) - **Styling**: Tailwind CSS, Radix UI
- React 18 - **State Management**: Redux Toolkit
- TypeScript - **API**: Go API (neomovies-api)
- Styled Components - **Database**: MongoDB
- JWT-based authentication (custom) - **Authentication**: JWT
- **Deployment**: Vercel
- **Backend:** ## 📦 Установка
- Node.js + Express (neomovies-api)
- MongoDB (native driver)
- **Дополнительно:** 1. **Клонируйте репозиторий:**
- ESLint
- Prettier
- Git
- npm
## Начало работы
1. Клонируйте репозиторий:
```bash ```bash
git clone https://gitlab.com/foxixus/neomovies.git git clone https://github.com/Ernous/neomovies-web.git
cd neomovies cd neomovies-web
``` ```
2. Установите зависимости: 2. **Установите зависимости:**
```bash ```bash
npm install npm install
``` ```
3. Создайте файл `.env` и добавьте следующие переменные: 3. Создайте файл `.env` и добавьте следующие переменные:
```env ```env
NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app NEXT_PUBLIC_API_URL=https://api.neomovies.ru
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
``` ```
4. **Запустите проект:** 4. **Запустите проект:**
```bash ```bash
# Режим разработки # Режим разработки
@@ -72,33 +55,37 @@ npm start
## API (neomovies-api) ## API (neomovies-api)
Приложение использует отдельный API сервер. API предоставляет следующие возможности: Приложение использует отдельный Go API сервер. API предоставляет следующие возможности:
- Поиск фильмов и сериалов - Поиск фильмов и сериалов через TMDB
- Получение детальной информации о фильме/сериале - Получение детальной информации о фильме/сериале
- Поиск торрентов по IMDB ID с парсингом сезонов из названий
- Система избранного и реакций
- JWT аутентификация с email верификацией
- Оптимизированная загрузка изображений - Оптимизированная загрузка изображений
- Кэширование запросов - Кэширование запросов
### Gmail App Password ### Особенности торрент-поиска
1. Включите двухфакторную аутентификацию в аккаунте Google
2. Перейдите в настройки безопасности
3. Создайте пароль приложения
4. Используйте этот пароль в GMAIL_APP_PASSWORD
Backend `.env` пример смотрите в репозитории [neomovies-api](https://gitlab.com/foxixus/neomovies-api). Новый API автоматически парсит сезоны из названий торрентов, что позволяет:
- Получать реальные доступные сезоны, а не только из TMDB
- Находить раздачи даже если нумерация сезонов отличается от официальной
- Группировать торренты по сезонам для удобного выбора
Backend `.env` пример смотрите в репозитории [neomovies-api](https://github.com/Ernous/neomovies-api).
--- ---
## Структура проекта ## Структура проекта
``` ```
neomovies/ neomovies-web/
├── src/ ├── src/
│ ├── app/ # App Router pages │ ├── app/ # App Router pages
│ ├── components/ # React компоненты │ ├── components/ # React компоненты
│ ├── hooks/ # React хуки │ ├── hooks/ # React хуки
│ ├── lib/ # Утилиты и API │ ├── lib/ # Утилиты и API
│ ├── models/ # MongoDB модели │ ├── types/ # TypeScript типы
│ └── styles/ # Глобальные стили │ └── styles/ # Глобальные стили
├── public/ # Статические файлы ├── public/ # Статические файлы
└── package.json └── package.json
@@ -108,6 +95,7 @@ neomovies/
## 👥 Авторы ## 👥 Авторы
- **Frontend Developer** - [Foxix](https://gitlab.com/foxixus) - **Frontend Developer** - [Foxix](https://gitlab.com/foxixus)
- **Backend Developer** - [Ernous](https://github.com/Ernous)
## 📄 Лицензия ## 📄 Лицензия
@@ -126,7 +114,7 @@ neomovies/
## Благодарности ## Благодарности
- [TMDB](https://www.themoviedb.org/) за предоставление API - [TMDB](https://www.themoviedb.org/) за предоставление API
- [Vercel](https://vercel.com/) за хостинг API - [Vercel](https://vercel.com/) за хостинг
## 📞 Контакты ## 📞 Контакты

View File

@@ -28,10 +28,16 @@ const nextConfig = {
port: '3000', port: '3000',
pathname: '/images/**', pathname: '/images/**',
}, },
// Продакшен на Vercel // Наш API прокси для изображений
{ {
protocol: 'https', protocol: 'https',
hostname: 'neomovies-api.vercel.app', hostname: 'neomovies-test-api.vercel.app',
pathname: '/api/v1/images/**',
},
// Продакшен на Vercel (старый)
{
protocol: 'https',
hostname: 'neomovies-test-api.vercel.app',
pathname: '/images/**', pathname: '/images/**',
}, },
{ {

839
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "neo-movies-web", "name": "neo-movies-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@tabler/icons-react": "^3.26.0", "@tabler/icons-react": "^3.26.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
@@ -20,7 +23,9 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -28,15 +33,18 @@
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"next": "15.1.2", "next": "15.1.2",
"next-seo": "^6.8.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-icons": "^5.5.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"resend": "^4.0.1", "resend": "^4.0.1",
"styled-components": "^6.1.13", "styled-components": "^6.1.13",
"tailwind": "^4.0.0" "tailwind": "^4.0.0",
"tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -45,6 +53,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.1.2", "eslint-config-next": "15.1.2",
"next-sitemap": "^4.2.3",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
@@ -72,6 +81,13 @@
"regenerator-runtime": "^0.12.0" "regenerator-runtime": "^0.12.0"
} }
}, },
"node_modules/@corex/deepmerge": {
"version": "4.0.43",
"resolved": "https://registry.npmjs.org/@corex/deepmerge/-/deepmerge-4.0.43.tgz",
"integrity": "sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -280,6 +296,44 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
"integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.3"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1056,6 +1110,562 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-email/render": { "node_modules/@react-email/render": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
@@ -1290,7 +1900,7 @@
"version": "19.1.6", "version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@@ -2128,6 +2738,18 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -2762,6 +3384,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": { "node_modules/classnames": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -2774,6 +3408,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3260,6 +3903,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -4652,6 +5301,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-own-enumerable-property-symbols": { "node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
@@ -6378,6 +7036,52 @@
} }
} }
}, },
"node_modules/next-seo": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.8.0.tgz",
"integrity": "sha512-zcxaV67PFXCSf8e6SXxbxPaOTgc8St/esxfsYXfQXMM24UESUVSXFm7f2A9HMkAwa0Gqn4s64HxYZAGfdF4Vhg==",
"license": "MIT",
"peerDependencies": {
"next": "^8.1.1-canary.54 || >=9.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/next-sitemap": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz",
"integrity": "sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==",
"dev": true,
"funding": [
{
"url": "https://github.com/iamvishnusankar/next-sitemap.git"
}
],
"license": "MIT",
"dependencies": {
"@corex/deepmerge": "^4.0.43",
"@next/env": "^13.4.3",
"fast-glob": "^3.2.12",
"minimist": "^1.2.8"
},
"bin": {
"next-sitemap": "bin/next-sitemap.mjs",
"next-sitemap-cjs": "bin/next-sitemap.cjs"
},
"engines": {
"node": ">=14.18"
},
"peerDependencies": {
"next": "*"
}
},
"node_modules/next-sitemap/node_modules/@next/env": {
"version": "13.5.11",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.11.tgz",
"integrity": "sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==",
"dev": true,
"license": "MIT"
},
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.4.6", "version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -7223,6 +7927,15 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7267,6 +7980,75 @@
} }
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8416,6 +9198,16 @@
"ws": "6.2.0" "ws": "6.2.0"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind/node_modules/ajv": { "node_modules/tailwind/node_modules/ajv": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
@@ -8919,6 +9711,49 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",

View File

@@ -10,6 +10,8 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@tabler/icons-react": "^3.26.0", "@tabler/icons-react": "^3.26.0",

9
public/robots.txt Normal file
View 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
View 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
View 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>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { api } from '@/lib/api'; import { neoApi } from '@/lib/neoApi';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -92,12 +92,12 @@ export default function AdminLoginClient() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await api.post('/auth/login', { const response = await neoApi.post('/api/v1/auth/login', {
email, email,
password, password,
}); });
const { token, user } = response.data; const { token, user } = response.data.data || response.data;
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
setError('У вас нет прав администратора.'); setError('У вас нет прав администратора.');

View File

@@ -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': '*'
}
}
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { categoriesAPI, Movie, TVShow } from '@/lib/api'; import { categoriesAPI, Movie } from '@/lib/neoApi';
import MovieCard from '@/components/MovieCard'; import MovieCard from '@/components/MovieCard';
import { ArrowLeft, Loader2 } from 'lucide-react'; import { ArrowLeft, Loader2 } from 'lucide-react';
@@ -60,10 +60,10 @@ function CategoryPage() {
response = await categoriesAPI.getTVShowsByCategory(categoryId, page); response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
const hasTvShows = response.data.results.length > 0; const hasTvShows = response.data.results.length > 0;
if (page === 1) setTvShowsAvailable(hasTvShows); if (page === 1) setTvShowsAvailable(hasTvShows);
const transformedShows = response.data.results.map((show: TVShow) => ({ const transformedShows = response.data.results.map((show: any) => ({
...show, ...show,
title: show.name, title: show.name || show.title,
release_date: show.first_air_date, release_date: show.first_air_date || show.release_date,
})); }));
setItems(transformedShows); setItems(transformedShows);
setTotalPages(response.data.total_pages); setTotalPages(response.data.total_pages);

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { categoriesAPI } from '@/lib/api'; import { categoriesAPI, Category } from '@/lib/neoApi';
import { Category } from '@/lib/api';
import CategoryCard from '@/components/CategoryCard'; import CategoryCard from '@/components/CategoryCard';
interface CategoryWithBackground extends Category { interface CategoryWithBackground extends Category {
@@ -14,14 +13,12 @@ function CategoriesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Загрузка категорий и фоновых изображений для них
useEffect(() => { useEffect(() => {
async function fetchCategoriesAndBackgrounds() { async function fetchCategoriesAndBackgrounds() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
// Получаем список категорий
const categoriesResponse = await categoriesAPI.getCategories(); const categoriesResponse = await categoriesAPI.getCategories();
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) { if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
@@ -30,14 +27,11 @@ function CategoriesPage() {
return; return;
} }
// Добавляем фоновые изображения для каждой категории
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all( const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
categoriesResponse.data.categories.map(async (category: Category) => { categoriesResponse.data.categories.map(async (category: Category) => {
try { try {
// Сначала пробуем получить фильм для фона
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1); const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
// Проверяем, есть ли фильмы в данной категории
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) { if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
const backgroundUrl = moviesResponse.data.results[0].backdrop_path || const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
moviesResponse.data.results[0].poster_path; moviesResponse.data.results[0].poster_path;
@@ -47,7 +41,6 @@ function CategoriesPage() {
backgroundUrl backgroundUrl
}; };
} else { } else {
// Если фильмов нет, пробуем получить сериалы
const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1); const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1);
if (tvResponse.data.results && tvResponse.data.results.length > 0) { if (tvResponse.data.results && tvResponse.data.results.length > 0) {
@@ -61,14 +54,13 @@ function CategoriesPage() {
} }
} }
// Если ни фильмов, ни сериалов не найдено
return { return {
...category, ...category,
backgroundUrl: undefined backgroundUrl: undefined
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching background for category ${category.id}:`, error); console.error(`Error fetching background for category ${category.id}:`, error);
return category; // Возвращаем категорию без фона в случае ошибки return category;
} }
}) })
); );

View File

@@ -49,3 +49,11 @@ body {
[data-nextjs-toast-wrapper] { [data-nextjs-toast-wrapper] {
display: none !important; display: none !important;
} }
/* Utility classes */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { moviesAPI } from '@/lib/neoApi'; import { moviesAPI } from '@/lib/neoApi';
import { getImageUrl } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi';
import type { MovieDetails } from '@/lib/api'; import type { MovieDetails } from '@/lib/neoApi';
import MoviePlayer from '@/components/MoviePlayer'; import MoviePlayer from '@/components/MoviePlayer';
import TorrentSelector from '@/components/TorrentSelector'; import TorrentSelector from '@/components/TorrentSelector';
import FavoriteButton from '@/components/FavoriteButton'; import FavoriteButton from '@/components/FavoriteButton';
@@ -20,23 +20,25 @@ interface MovieContentProps {
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) { export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
const [movie] = useState<MovieDetails>(initialMovie); const [movie] = useState<MovieDetails>(initialMovie);
const [externalIds, setExternalIds] = useState<any>(null);
const [imdbId, setImdbId] = useState<string | null>(null); const [imdbId, setImdbId] = useState<string | null>(null);
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false); const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
const [isControlsVisible, setIsControlsVisible] = useState(false); const [isControlsVisible, setIsControlsVisible] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null); const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const fetchImdbId = async () => { const fetchExternalIds = async () => {
try { try {
const { data } = await moviesAPI.getMovie(movieId); const data = await moviesAPI.getExternalIds(movieId);
setExternalIds(data);
if (data?.imdb_id) { if (data?.imdb_id) {
setImdbId(data.imdb_id); setImdbId(data.imdb_id);
} }
} catch (err) { } catch (err) {
console.error('Error fetching IMDb ID:', err); console.error('Error fetching external ids:', err);
} }
}; };
fetchImdbId(); fetchExternalIds();
}, [movieId]); }, [movieId]);
const showControls = () => { const showControls = () => {
@@ -182,6 +184,9 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
<TorrentSelector <TorrentSelector
imdbId={imdbId} imdbId={imdbId}
type="movie" type="movie"
title={movie.title}
originalTitle={movie.original_title}
year={movie.release_date?.split('-')[0]}
/> />
</div> </div>
)} )}

View File

@@ -2,7 +2,7 @@
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import MovieContent from './MovieContent'; import MovieContent from './MovieContent';
import type { MovieDetails } from '@/lib/api'; import type { MovieDetails } from '@/lib/neoApi';
interface MoviePageProps { interface MoviePageProps {
movieId: string; movieId: string;

View File

@@ -1,5 +1,5 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { moviesAPI } from '@/lib/api'; import { moviesAPI } from '@/lib/neoApi';
import MoviePage from '@/app/movie/[id]/MoviePage'; import MoviePage from '@/app/movie/[id]/MoviePage';
interface PageProps { interface PageProps {
@@ -8,19 +8,13 @@ interface PageProps {
}; };
} }
// Генерация метаданных для страницы
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> { export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
const { params } = await props; const { params } = await props;
// В Next.js 14, нужно сначала получить данные фильма,
// а затем использовать их для метаданных
try { try {
// Получаем id для использования в запросе
const movieId = params.id; const movieId = params.id;
// Запрашиваем данные фильма
const { data: movie } = await moviesAPI.getMovie(movieId); const { data: movie } = await moviesAPI.getMovie(movieId);
// Создаем метаданные на основе полученных данных
return { return {
title: `${movie.title} - NeoMovies`, title: `${movie.title} - NeoMovies`,
description: movie.overview, description: movie.overview,
@@ -33,7 +27,6 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
} }
} }
// Получение данных для страницы
async function getData(id: string) { async function getData(id: string) {
try { try {
const { data: movie } = await moviesAPI.getMovie(id); const { data: movie } = await moviesAPI.getMovie(id);

View File

@@ -49,9 +49,9 @@ export default function HomePage() {
Популярные Популярные
</button> </button>
<button <button
onClick={() => setActiveTab('now_playing')} onClick={() => setActiveTab('now-playing')}
className={`${ className={`${
activeTab === 'now_playing' activeTab === 'now-playing'
? 'border-red-500 text-red-600' ? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`} } whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
@@ -59,15 +59,25 @@ export default function HomePage() {
Новинки Новинки
</button> </button>
<button <button
onClick={() => setActiveTab('top_rated')} onClick={() => setActiveTab('top-rated')}
className={`${ className={`${
activeTab === 'top_rated' activeTab === 'top-rated'
? 'border-red-500 text-red-600' ? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`} } whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
> >
Топ рейтинга Топ рейтинга
</button> </button>
<button
onClick={() => setActiveTab('upcoming')}
className={`${
activeTab === 'upcoming'
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
>
Скоро
</button>
</nav> </nav>
</div> </div>

View File

@@ -2,30 +2,24 @@
import { useState, useEffect, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api'; import { searchAPI } from '@/lib/neoApi';
import type { Movie } from '@/lib/neoApi';
import MovieCard from '@/components/MovieCard'; import MovieCard from '@/components/MovieCard';
export default function SearchClient() { export default function SearchClient() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || ''); const [query, setQuery] = useState(searchParams.get('q') || '');
const [results, setResults] = useState<(Movie | TVShow)[]>([]); const [results, setResults] = useState<Movie[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
const currentQuery = searchParams.get('q'); const currentQuery = searchParams.get('q');
if (currentQuery) { if (currentQuery) {
setLoading(true); setLoading(true);
Promise.all([ searchAPI.multiSearch(currentQuery)
moviesAPI.searchMovies(currentQuery), .then((response) => {
tvAPI.searchShows(currentQuery), setResults(response.data.results || []);
])
.then(([movieResults, tvResults]) => {
const combined = [
...(movieResults.data.results || []),
...(tvResults.data.results || []),
];
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
}) })
.catch((error) => { .catch((error) => {
console.error('Search failed:', error); console.error('Search failed:', error);
@@ -56,7 +50,7 @@ export default function SearchClient() {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{results.map((item) => ( {results.map((item) => (
<MovieCard <MovieCard
key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`} key={`${item.id}-${item.media_type || 'movie'}`}
movie={item} movie={item}
/> />
))} ))}

View File

@@ -2,9 +2,9 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { tvAPI } from '@/lib/api'; import { tvShowsAPI } from '@/lib/neoApi';
import { getImageUrl } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi';
import type { TVShowDetails } from '@/lib/api'; import type { TVShowDetails } from '@/lib/neoApi';
import MoviePlayer from '@/components/MoviePlayer'; import MoviePlayer from '@/components/MoviePlayer';
import TorrentSelector from '@/components/TorrentSelector'; import TorrentSelector from '@/components/TorrentSelector';
import FavoriteButton from '@/components/FavoriteButton'; import FavoriteButton from '@/components/FavoriteButton';
@@ -20,29 +20,28 @@ interface TVContentProps {
export default function TVContent({ showId, initialShow }: TVContentProps) { export default function TVContent({ showId, initialShow }: TVContentProps) {
const [show] = useState<TVShowDetails>(initialShow); const [show] = useState<TVShowDetails>(initialShow);
const [externalIds, setExternalIds] = useState<any>(null);
const [imdbId, setImdbId] = useState<string | null>(null); const [imdbId, setImdbId] = useState<string | null>(null);
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false); const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
const [isControlsVisible, setIsControlsVisible] = useState(false); const [isControlsVisible, setIsControlsVisible] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null); const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const fetchImdbId = async () => { const fetchExternalIds = async () => {
try { try {
// Используем dedicated эндпоинт для получения IMDb ID const data = await tvShowsAPI.getExternalIds(showId);
const { data } = await tvAPI.getImdbId(showId); setExternalIds(data);
if (data?.imdb_id) { if (data?.imdb_id) {
setImdbId(data.imdb_id); setImdbId(data.imdb_id);
} }
} catch (err) { } catch (err) {
console.error('Error fetching IMDb ID:', err); console.error('Error fetching external ids:', err);
} }
}; };
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
if (initialShow.external_ids?.imdb_id) { if (initialShow.external_ids?.imdb_id) {
setImdbId(initialShow.external_ids.imdb_id); setImdbId(initialShow.external_ids.imdb_id);
} else { } else {
fetchImdbId(); fetchExternalIds();
} }
}, [showId, initialShow.external_ids]); }, [showId, initialShow.external_ids]);
@@ -185,7 +184,9 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
<TorrentSelector <TorrentSelector
imdbId={imdbId} imdbId={imdbId}
type="tv" type="tv"
totalSeasons={show.number_of_seasons} title={show.name}
originalTitle={show.original_name}
year={show.first_air_date?.split('-')[0]}
/> />
</div> </div>
)} )}

View File

@@ -2,7 +2,7 @@
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import TVContent from '@/app/tv/[id]/TVContent'; import TVContent from '@/app/tv/[id]/TVContent';
import type { TVShowDetails } from '@/lib/api'; import type { TVShowDetails } from '@/lib/neoApi';
interface TVPageProps { interface TVPageProps {
showId: string; showId: string;

View File

@@ -1,5 +1,5 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { tvAPI } from '@/lib/api'; import { tvShowsAPI } from '@/lib/neoApi';
import TVPage from '@/app/tv/[id]/TVPage'; import TVPage from '@/app/tv/[id]/TVPage';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -10,12 +10,11 @@ interface PageProps {
}; };
} }
// Генерация метаданных для страницы
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> { export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
const { params } = await props; const { params } = await props;
try { try {
const showId = params.id; const showId = params.id;
const { data: show } = await tvAPI.getShow(showId); const { data: show } = await tvShowsAPI.getTVShow(showId);
return { return {
title: `${show.name} - NeoMovies`, title: `${show.name} - NeoMovies`,
@@ -29,10 +28,9 @@ export async function generateMetadata(props: Promise<PageProps>): Promise<Metad
} }
} }
// Получение данных для страницы
async function getData(id: string) { async function getData(id: string) {
try { try {
const { data: show } = await tvAPI.getShow(id); const { data: show } = await tvShowsAPI.getTVShow(id);
return { id, show }; return { id, show };
} catch (error) { } catch (error) {
throw new Error('Failed to fetch TV show'); throw new Error('Failed to fetch TV show');

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Category } from '@/lib/api'; import { Category } from '@/lib/neoApi';
interface CategoryCardProps { interface CategoryCardProps {
category: Category; category: Category;

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { moviesAPI, api } from '@/lib/api'; import { moviesAPI } from '@/lib/neoApi';
import { AlertTriangle, Info } from 'lucide-react'; import { AlertTriangle, Info } from 'lucide-react';
interface MoviePlayerProps { interface MoviePlayerProps {
@@ -22,7 +22,10 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
useEffect(() => { useEffect(() => {
const fetchImdbId = async () => { const fetchImdbId = async () => {
if (imdbId) return; if (imdbId) {
setResolvedImdb(imdbId);
return;
}
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -40,42 +43,43 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
}, [id, imdbId]); }, [id, imdbId]);
useEffect(() => { useEffect(() => {
const loadPlayer = async () => { if (!isInitialized || !resolvedImdb) return;
if (!isInitialized || !resolvedImdb) return;
try {
setLoading(true);
setError(null);
const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex';
const { data } = await api.get(basePath, { params: { imdb_id: resolvedImdb } });
if (!data) throw new Error('Empty response');
let src: string | null = data.iframe || data.src || data.url || null; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
if (!src && typeof data === 'string') { if (!API_BASE_URL) {
const match = data.match(/<iframe[^>]*src="([^"]+)"/i); setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.');
if (match && match[1]) src = match[1]; return;
} }
if (!src) throw new Error('Invalid response format');
setIframeSrc(src); const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex';
} catch (err) {
console.error(err); // Формируем URL, где imdbId является частью пути
setError('Не удалось загрузить плеер. Попробуйте позже.'); const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
} finally {
setLoading(false); setIframeSrc(newIframeSrc);
} setLoading(false);
};
loadPlayer();
}, [resolvedImdb, isInitialized, settings.defaultPlayer]); }, [resolvedImdb, isInitialized, settings.defaultPlayer]);
const handleRetry = () => { const handleRetry = () => {
setError(null); setError(null);
if (!resolvedImdb) { if (!resolvedImdb) {
// Re-fetch IMDb ID
const event = new Event('fetchImdb'); const event = new Event('fetchImdb');
window.dispatchEvent(event); window.dispatchEvent(event);
} else { } else {
// Re-load player setIframeSrc(null);
const event = new Event('loadPlayer'); setLoading(true);
window.dispatchEvent(event);
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_BASE_URL) {
setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.');
setLoading(false);
return;
}
const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex';
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
setIframeSrc(newIframeSrc);
setLoading(false);
} }
}; };

View File

@@ -1,205 +1,374 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2, AlertTriangle, Copy, Check } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react';
interface Torrent { import { torrentsAPI, type TorrentResult } from '@/lib/neoApi';
magnet: string;
title?: string;
name?: string;
quality?: string;
seeders?: number;
size_gb?: number;
}
interface GroupedTorrents {
[quality: string]: Torrent[];
}
interface SeasonGroupedTorrents {
[season: string]: GroupedTorrents;
}
interface TorrentSelectorProps { interface TorrentSelectorProps {
imdbId: string | null; imdbId: string | null;
type: 'movie' | 'tv'; type: 'movie' | 'tv';
totalSeasons?: number; title?: string;
originalTitle?: string;
year?: string;
} }
export default function TorrentSelector({ imdbId, type, totalSeasons }: TorrentSelectorProps) { interface ParsedTorrent extends TorrentResult {
const [torrents, setTorrents] = useState<Torrent[] | null>(null); quality?: string;
const [selectedSeason, setSelectedSeason] = useState<number | null>(type === 'movie' ? 1 : null); season?: number;
const [selectedMagnet, setSelectedMagnet] = useState<string | null>(null); sizeFormatted?: string;
}
export default function TorrentSelector({ imdbId, type, title, originalTitle, year }: TorrentSelectorProps) {
const [torrents, setTorrents] = useState<ParsedTorrent[] | null>(null);
const [availableSeasons, setAvailableSeasons] = useState<number[]>([]);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedQuality, setSelectedQuality] = useState<string>('all');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false); const [copiedMagnet, setCopiedMagnet] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// Для TV показов автоматически выбираем первый сезон const parseQuality = (title: string): string => {
useEffect(() => { const qualityRegex = /(2160p|4K|UHD|1080p|FHD|720p|HD|480p|SD|CAMRip|TS|TC|DVDRip|BDRip|WEBRip|HDTV)/i;
if (type === 'tv' && totalSeasons && totalSeasons > 0 && !selectedSeason) { const match = title.match(qualityRegex);
setSelectedSeason(1); if (match) {
const quality = match[1].toUpperCase();
if (quality === 'UHD' || quality === '4K') return '4K';
if (quality === 'FHD') return '1080P';
if (quality === 'HD' && !title.match(/720p/i)) return '720P';
if (quality === 'SD') return '480P';
return quality;
} }
}, [type, totalSeasons, selectedSeason]); return 'UNKNOWN';
};
const parseSeason = (title: string): number | undefined => {
const seasonRegexes = [
/(?:S|Season\s*)(\d+)/i,
/Сезон\s*(\d+)/i,
/Season\s*(\d+)/i,
/S(\d+)E\d+/i,
/(\d+)\s*сезон/i
];
for (const regex of seasonRegexes) {
const match = title.match(regex);
if (match) {
return parseInt(match[1], 10);
}
}
return undefined;
};
const formatSize = (size: string | number): string => {
if (!size) return '';
let sizeNum = Number(size);
if (!isNaN(sizeNum) && sizeNum > 0) {
if (sizeNum > 1024 * 1024 * 1024) {
return (sizeNum / (1024 * 1024 * 1024)).toFixed(2) + ' ГБ';
} else if (sizeNum > 1024 * 1024) {
return (sizeNum / (1024 * 1024)).toFixed(2) + ' МБ';
} else {
return (sizeNum / 1024).toFixed(2) + ' КБ';
}
}
return size.toString();
};
useEffect(() => {
if (type === 'tv' && torrents && torrents.length > 0) {
const seasons = [...new Set(
torrents
.map(t => t.season)
.filter(s => s !== undefined)
.sort((a, b) => a! - b!)
)] as number[];
setAvailableSeasons(seasons);
}
}, [type, torrents]);
useEffect(() => { useEffect(() => {
if (!imdbId) return; if (!imdbId) return;
fetchTorrents();
// Для фильмов загружаем сразу }, [imdbId, type]);
if (type === 'movie') {
fetchTorrents();
}
// Для TV показов загружаем только когда выбран сезон
else if (type === 'tv' && selectedSeason) {
fetchTorrents();
}
}, [imdbId, type, selectedSeason]);
const fetchTorrents = async () => { const fetchTorrents = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setSelectedMagnet(null);
try { try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL; const response = await torrentsAPI.searchTorrents(imdbId!, type);
if (!apiUrl) { if (response.data.total === 0) {
throw new Error('API URL не настроен');
}
let url = `${apiUrl}/torrents/search/${imdbId}?type=${type}`;
if (type === 'tv' && selectedSeason) {
url += `&season=${selectedSeason}`;
}
console.log('API URL:', url, 'IMDB:', imdbId, 'season:', selectedSeason);
const res = await fetch(url);
if (!res.ok) {
throw new Error('Failed to fetch torrents');
}
const data = await res.json();
if (data.total === 0) {
setError('Торренты не найдены.'); setError('Торренты не найдены.');
} else { } else {
setTorrents(data.results as Torrent[]); const parsedTorrents: ParsedTorrent[] = response.data.results.map(torrent => ({
...torrent,
quality: parseQuality(torrent.title || ''),
season: type === 'tv' ? parseSeason(torrent.title || '') : undefined,
sizeFormatted: formatSize(torrent.size || 0)
}));
setTorrents(parsedTorrents);
} }
} catch (err) { } catch (err) {
console.error(err);
setError('Не удалось загрузить список торрентов.'); setError('Не удалось загрузить список торрентов.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleQualitySelect = (torrent: Torrent) => { const filteredTorrents = useMemo(() => {
setSelectedMagnet(torrent.magnet); if (!torrents) return [];
setIsCopied(false);
let filtered = torrents;
if (type === 'tv' && selectedSeason !== null && availableSeasons.length > 0) {
filtered = filtered.filter(torrent => torrent.season === selectedSeason);
}
if (selectedQuality !== 'all') {
filtered = filtered.filter(torrent => torrent.quality === selectedQuality);
}
return filtered.sort((a, b) => {
const qualityOrder = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP'];
const aQualityIndex = qualityOrder.indexOf(a.quality || '');
const bQualityIndex = qualityOrder.indexOf(b.quality || '');
if (aQualityIndex === -1 && bQualityIndex === -1) return 0;
if (aQualityIndex === -1) return 1;
if (bQualityIndex === -1) return -1;
return aQualityIndex - bQualityIndex;
});
}, [torrents, selectedSeason, selectedQuality, type, availableSeasons]);
const availableQualities = useMemo(() => {
if (!torrents) return [];
const qualities = [...new Set(torrents.map(t => t.quality).filter(q => q && q !== 'UNKNOWN'))];
return qualities.sort((a, b) => {
const order = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP', 'CAMRIP', 'TS', 'TC'];
const indexA = order.indexOf(a!);
const indexB = order.indexOf(b!);
if (indexA === -1 && indexB === -1) return a!.localeCompare(b!);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}, [torrents]);
const handleCopy = (magnet: string) => {
navigator.clipboard.writeText(magnet);
setCopiedMagnet(magnet);
setTimeout(() => setCopiedMagnet(null), 2000);
}; };
const handleCopy = () => { const handleDownload = (magnet: string) => {
if (!selectedMagnet) return; window.open(magnet, '_blank');
navigator.clipboard.writeText(selectedMagnet); };
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000); const TorrentCard = ({ torrent }: { torrent: ParsedTorrent }) => {
const getAdditionalInfo = (title: string) => {
const info: string[] = [];
if (title.match(/rus/i)) info.push('RUS');
if (title.match(/eng/i)) info.push('ENG');
if (title.match(/x264/i)) info.push('x264');
if (title.match(/x265|HEVC/i)) info.push('HEVC');
if (title.match(/HDR/i)) info.push('HDR');
if (title.match(/Dolby/i)) info.push('Dolby');
return info;
};
const additionalInfo = getAdditionalInfo(torrent.title || '');
return (
<div className="border border-gray-200 rounded-lg p-4 space-y-3 bg-white dark:bg-zinc-800 hover:bg-gray-50 transition-colors dark:hover:bg-zinc-700">
<div className="flex flex-col gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm leading-tight break-words line-clamp-2 text-gray-900 dark:text-gray-50">
{torrent.title || 'Раздача'}
</h4>
<div className="flex flex-wrap gap-2 mt-2">
{torrent.quality && torrent.quality !== 'UNKNOWN' && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{torrent.quality}
</span>
)}
{type === 'tv' && torrent.season && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200">
Сезон {torrent.season}
</span>
)}
{torrent.sizeFormatted && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-50 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{torrent.sizeFormatted}
</span>
)}
{additionalInfo.map(info => (
<span key={info} className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{info}
</span>
))}
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={() => handleCopy(torrent.magnet)}
variant="outline"
size="sm"
className="flex-1 border-gray-300 text-gray-700 dark:border-zinc-600 dark:text-zinc-300"
>
{copiedMagnet === torrent.magnet ? (
<>
<Check className="h-4 w-4 mr-2 text-green-500" />
Скопировано
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Копировать
</>
)}
</Button>
<Button
onClick={() => handleDownload(torrent.magnet)}
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white dark:bg-blue-700 dark:hover:bg-blue-800"
>
<ExternalLink className="h-4 w-4 mr-2" />
Скачать
</Button>
</div>
</div>
);
}; };
if (loading) { if (loading) {
return ( return (
<div className="mt-4 flex items-center justify-center p-4"> <div className="mt-4 flex items-center justify-center p-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin text-gray-500 dark:text-gray-400" />
<span>Загрузка торрентов...</span> <span className="text-gray-700 dark:text-gray-300">Загрузка торрентов...</span>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-100 p-3 text-sm text-red-700"> <div className="mt-4 flex items-center gap-2 rounded-md bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-400">
<AlertTriangle size={20} /> <AlertTriangle size={20} className="text-red-600 dark:text-red-500" />
<span>{error}</span> <span>{error}</span>
</div> </div>
); );
} }
if (!torrents) return null; if (!torrents || torrents.length === 0) {
return null;
const renderTorrentButtons = (list: Torrent[]) => { }
if (!list?.length) {
return (
<p className="text-sm text-muted-foreground">
Торрентов для выбранного сезона нет.
</p>
);
}
return list.map(torrent => {
const size = torrent.size_gb;
const label = torrent.title || torrent.name || 'Раздача';
return (
<Button
key={torrent.magnet}
asChild
onClick={() => handleQualitySelect(torrent)}
variant="outline"
className="w-full items-center text-left px-3 py-2"
>
<a
href={torrent.magnet}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center"
>
<span className="flex-1 truncate whitespace-nowrap overflow-hidden">{label}</span>
{size !== undefined && (
<span className="text-xs text-muted-foreground">{size.toFixed(2)} GB</span>
)}
</a>
</Button>
);
});
};
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4">
{type === 'tv' && totalSeasons && totalSeasons > 0 && ( <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<div> <DialogTrigger asChild>
<h3 className="text-lg font-semibold mb-2">Сезоны</h3> <Button className="w-full sm:w-auto" size="lg">
<div className="flex flex-wrap gap-2"> <Download className="h-4 w-4 mr-2" />
{Array.from({ length: totalSeasons }, (_, i) => i + 1).map(season => ( Скачать ({torrents.length} {torrents.length === 1 ? 'раздача' : torrents.length < 5 ? 'раздачи' : 'раздач'})
<Button </Button>
key={season} </DialogTrigger>
onClick={() => {setSelectedSeason(season); setSelectedMagnet(null);}}
variant={selectedSeason === season ? 'default' : 'outline'} <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-zinc-900">
> <DialogHeader>
Сезон {season} <DialogTitle className="text-gray-900 dark:text-gray-50">
</Button> Выберите раздачу для скачивания
))} </DialogTitle>
</div> <div className="text-sm text-gray-500 dark:text-gray-400">
</div> Найдено {filteredTorrents.length} из {torrents.length} раздач
)} </div>
</DialogHeader>
{selectedSeason && torrents && (
<div> <div className="space-y-4">
<h3 className="text-lg font-semibold mb-2">Раздачи</h3> <div className="space-y-4">
<div className="space-y-2"> {availableQualities.length > 0 && (
{renderTorrentButtons(torrents)} <div>
</div> <label className="text-sm font-medium mb-3 block text-gray-900 dark:text-gray-50">Качество</label>
</div> <div className="flex flex-wrap gap-2">
)} <Button
variant={selectedQuality === 'all' ? 'default' : 'outline'}
{selectedMagnet && ( size="sm"
<div className="mt-4"> onClick={() => setSelectedQuality('all')}
<h3 className="text-lg font-semibold mb-2">Magnet-ссылка</h3> 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'}
<div className="flex items-center gap-2"> >
<div className="flex-1 rounded-md border bg-secondary/50 px-3 py-2 text-sm"> Все ({torrents.length})
{selectedMagnet} </Button>
{availableQualities.map(quality => {
const count = torrents.filter(t =>
t.quality === quality &&
(type !== 'tv' || selectedSeason === null || availableSeasons.length === 0 || t.season === selectedSeason)
).length;
return (
<Button
key={quality}
variant={selectedQuality === quality ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedQuality(quality!)}
className={selectedQuality === quality ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
>
{quality} ({count})
</Button>
);
})}
</div>
</div>
)}
{type === 'tv' && availableSeasons.length > 0 && (
<div className="mb-4">
<label className="text-sm font-medium mb-3 block text-gray-900 dark:text-gray-50">Сезон</label>
<div className="flex flex-wrap gap-2">
<Button
variant={selectedSeason === null ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedSeason(null)}
className={selectedSeason === null ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
>
Все сезоны
</Button>
{availableSeasons.map(season => {
const count = torrents?.filter(t => t.season === season && (selectedQuality === 'all' || t.quality === selectedQuality)).length || 0;
return (
<Button
key={season}
variant={selectedSeason === season ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedSeason(season)}
className={selectedSeason === season ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
>
Сезон {season} ({count})
</Button>
);
})}
</div>
</div>
)}
</div>
<div className="space-y-3">
{filteredTorrents.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Нет раздач, соответствующих выбранным фильтрам
</div>
) : (
filteredTorrents.map((torrent, index) => (
<TorrentCard key={`${torrent.magnet}-${index}`} torrent={torrent} />
))
)}
</div> </div>
<Button onClick={handleCopy} size="icon" variant="outline">
{isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div> </div>
</div> </DialogContent>
)} </Dialog>
</div> </div>
); );
} }

View 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,
}

View 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,
}

View File

@@ -2,7 +2,7 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { authAPI } from '../lib/authApi'; import { authAPI } from '../lib/authApi';
import { api } from '../lib/api'; import { neoApi } from '../lib/neoApi';
interface PendingRegistration { interface PendingRegistration {
email: string; email: string;
@@ -16,34 +16,35 @@ export function useAuth() {
const [pending, setPending] = useState<PendingRegistration | null>(null); const [pending, setPending] = useState<PendingRegistration | null>(null);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const { data } = await authAPI.login(email, password); try {
if (data?.token) { const response = await authAPI.login(email, password);
localStorage.setItem('token', data.token); const data = response.data.data || response.data;
if (data?.token) {
// Extract name/email either from API response or JWT payload localStorage.setItem('token', data.token);
let name: string | undefined = undefined; let name: string | undefined = undefined;
let email: string | undefined = undefined; let emailVal: string | undefined = undefined;
try { try {
const payload = JSON.parse(atob(data.token.split('.')[1])); const payload = JSON.parse(atob(data.token.split('.')[1]));
name = payload.name || payload.username || payload.userName || payload.sub || undefined; name = payload.name || payload.username || payload.userName || payload.sub || undefined;
email = payload.email || undefined; emailVal = payload.email || undefined;
} catch { } catch {}
// silent if (!name) name = data.user?.name || data.name || data.userName;
if (!emailVal) emailVal = data.user?.email || data.email;
if (name) localStorage.setItem('userName', name);
if (emailVal) localStorage.setItem('userEmail', emailVal);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
neoApi.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
router.push('/');
} else {
throw new Error(data?.error || 'Login failed');
} }
if (!name) name = data.user?.name || data.name || data.userName; } catch (err: any) {
if (!email) email = data.user?.email || data.email; if (err?.response?.status === 401 || err?.response?.status === 400) {
throw new Error('Неверный логин или пароль');
if (name) localStorage.setItem('userName', name);
if (email) localStorage.setItem('userEmail', email);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
} }
throw new Error(err?.message || 'Произошла ошибка');
api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
router.push('/');
} else {
throw new Error(data?.error || 'Login failed');
} }
}; };
@@ -66,14 +67,11 @@ export function useAuth() {
setPending(pendingData); setPending(pendingData);
} }
} }
if (!pendingData) { if (!pendingData) {
throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.'); throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.');
} }
await authAPI.verify(pendingData.email, code); await authAPI.verify(pendingData.email, code);
await login(pendingData.email, pendingData.password); await login(pendingData.email, pendingData.password);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('pendingVerification'); localStorage.removeItem('pendingVerification');
} }
@@ -83,7 +81,7 @@ export function useAuth() {
const logout = () => { const logout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization']; delete neoApi.defaults.headers.common['Authorization'];
localStorage.removeItem('userName'); localStorage.removeItem('userName');
localStorage.removeItem('userEmail'); localStorage.removeItem('userEmail');
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { moviesAPI } from '@/lib/neoApi'; import { moviesAPI } from '@/lib/neoApi';
import type { Movie, MovieResponse } from '@/lib/neoApi'; import type { Movie, MovieResponse } from '@/lib/neoApi';
export type MovieCategory = 'popular' | 'top_rated' | 'now_playing'; export type MovieCategory = 'popular' | 'top-rated' | 'now-playing' | 'upcoming';
interface UseMoviesProps { interface UseMoviesProps {
initialPage?: number; initialPage?: number;
@@ -26,12 +26,15 @@ export function useMovies({ initialPage = 1, category = 'popular' }: UseMoviesPr
let response: { data: MovieResponse }; let response: { data: MovieResponse };
switch (movieCategory) { switch (movieCategory) {
case 'top_rated': case 'top-rated':
response = await moviesAPI.getTopRated(pageNum); response = await moviesAPI.getTopRated(pageNum);
break; break;
case 'now_playing': case 'now-playing':
response = await moviesAPI.getNowPlaying(pageNum); response = await moviesAPI.getNowPlaying(pageNum);
break; break;
case 'upcoming':
response = await moviesAPI.getUpcoming(pageNum);
break;
case 'popular': case 'popular':
default: default:
response = await moviesAPI.getPopular(pageNum); response = await moviesAPI.getPopular(pageNum);

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { moviesAPI } from '@/lib/api'; import { searchAPI } from '@/lib/neoApi';
import type { Movie } from '@/lib/api'; import type { Movie } from '@/lib/neoApi';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -48,7 +48,7 @@ export function useSearch() {
setCurrentQuery(query); setCurrentQuery(query);
setCurrentPage(1); setCurrentPage(1);
const response = await moviesAPI.searchMovies(query, 1); const response = await searchAPI.multiSearch(query, 1);
const filteredMovies = filterMovies(response.data.results); const filteredMovies = filterMovies(response.data.results);
if (filteredMovies.length === 0) { if (filteredMovies.length === 0) {
@@ -74,7 +74,7 @@ export function useSearch() {
setLoading(true); setLoading(true);
const nextPage = currentPage + 1; const nextPage = currentPage + 1;
const response = await moviesAPI.searchMovies(currentQuery, nextPage); const response = await searchAPI.multiSearch(currentQuery, nextPage);
const filteredMovies = filterMovies(response.data.results); const filteredMovies = filterMovies(response.data.results);
setResults(prev => [...prev, ...filteredMovies]); setResults(prev => [...prev, ...filteredMovies]);

View File

@@ -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
};
}

View File

@@ -17,8 +17,7 @@ export function useUser() {
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
try { try {
// Сначала проверяем, верифицирован ли аккаунт const verificationCheck = await fetch('/api/v1/auth/check-verification', {
const verificationCheck = await fetch('/api/auth/check-verification', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
@@ -27,8 +26,7 @@ export function useUser() {
const { isVerified } = await verificationCheck.json(); const { isVerified } = await verificationCheck.json();
if (!isVerified) { if (!isVerified) {
// Если аккаунт не верифицирован, отправляем новый код и переходим к верификации const verificationResponse = await fetch('/api/v1/auth/verify', {
const verificationResponse = await fetch('/api/auth/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
@@ -43,15 +41,28 @@ export function useUser() {
return; return;
} }
// Если аккаунт верифицирован, выполняем вход const loginResponse = await fetch('/api/v1/auth/login', {
const result = await signIn('credentials', { method: 'POST',
redirect: false, headers: { 'Content-Type': 'application/json' },
email, body: JSON.stringify({ email, password })
password,
}); });
if (result?.error) { if (!loginResponse.ok) {
throw new Error(result.error); const data = await loginResponse.json();
throw new Error(data.error || 'Неверный email или пароль');
}
const loginData = await loginResponse.json();
const { token, user } = loginData.data || loginData;
if (token) {
localStorage.setItem('token', token);
if (user?.name) localStorage.setItem('userName', user.name);
if (user?.email) localStorage.setItem('userEmail', user.email);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
} }
router.push('/'); router.push('/');
@@ -62,7 +73,7 @@ export function useUser() {
const register = async (email: string, password: string, name: string) => { const register = async (email: string, password: string, name: string) => {
try { try {
const response = await fetch('/api/auth/register', { const response = await fetch('/api/v1/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }), body: JSON.stringify({ email, password, name }),
@@ -73,8 +84,7 @@ export function useUser() {
throw new Error(data.error || 'Ошибка при регистрации'); throw new Error(data.error || 'Ошибка при регистрации');
} }
// Отправляем код подтверждения const verificationResponse = await fetch('/api/v1/auth/verify', {
const verificationResponse = await fetch('/api/auth/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
@@ -98,7 +108,7 @@ export function useUser() {
} }
try { try {
const response = await fetch('/api/auth/verify', { const response = await fetch('/api/v1/auth/verify', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -112,15 +122,31 @@ export function useUser() {
throw new Error(data.error || 'Неверный код подтверждения'); throw new Error(data.error || 'Неверный код подтверждения');
} }
// После успешной верификации выполняем вход const loginResponse = await fetch('/api/v1/auth/login', {
const result = await signIn('credentials', { method: 'POST',
redirect: false, headers: { 'Content-Type': 'application/json' },
email: pendingRegistration.email, body: JSON.stringify({
password: pendingRegistration.password, email: pendingRegistration.email,
password: pendingRegistration.password
})
}); });
if (result?.error) { if (!loginResponse.ok) {
throw new Error(result.error); const data = await loginResponse.json();
throw new Error(data.error || 'Ошибка входа после верификации');
}
const loginData = await loginResponse.json();
const { token, user } = loginData.data || loginData;
if (token) {
localStorage.setItem('token', token);
if (user?.name) localStorage.setItem('userName', user.name);
if (user?.email) localStorage.setItem('userEmail', user.email);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
} }
setIsVerifying(false); setIsVerifying(false);
@@ -132,7 +158,15 @@ export function useUser() {
}; };
const logout = () => { const logout = () => {
signOut({ callbackUrl: '/login' }); localStorage.removeItem('token');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
router.push('/login');
}; };
return { return {

View File

@@ -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 }
});
}
};

View File

@@ -1,19 +1,10 @@
import { api } from './api'; import { neoApi } from './neoApi';
export const authAPI = { export const authAPI = {
register(data: { email: string; password: string; name?: string }) { register: (data: any) => neoApi.post('/api/v1/auth/register', data),
return api.post('/auth/register', data); resendCode: (email: string) => neoApi.post('/api/v1/auth/resend-code', { email }),
}, verify: (email: string, code: string) => neoApi.post('/api/v1/auth/verify', { email, code }),
resendCode(email: string) { checkVerification: (email: string) => neoApi.post('/api/v1/auth/check-verification', { email }),
return api.post('/auth/resend-code', { email }); login: (email: string, password: string) => neoApi.post('/api/v1/auth/login', { email, password }),
}, deleteAccount: () => neoApi.delete('/api/v1/auth/profile'),
verify(email: string, code: string) {
return api.post('/auth/verify', { email, code });
},
login(email: string, password: string) {
return api.post('/auth/login', { email, password });
},
deleteAccount() {
return api.delete('/auth/profile');
}
}; };

View File

@@ -1,25 +1,24 @@
import { api } from './api'; import { neoApi } from './neoApi';
export const favoritesAPI = { export const favoritesAPI = {
// Получить все избранные // Получение всех избранных
getFavorites() { getFavorites() {
return api.get('/favorites'); return neoApi.get('/api/v1/favorites');
}, },
// Добавить в избранное // Добавление в избранное
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) { addFavorite(data: { mediaId: string; mediaType: string; title: string; posterPath?: string }) {
const { mediaId, mediaType, title, posterPath } = data; const { mediaId, mediaType, ...rest } = data;
return api.post(`/favorites/${mediaId}?mediaType=${mediaType}`, { title, posterPath }); return neoApi.post(`/api/v1/favorites/${mediaId}?mediaType=${mediaType}`, rest);
}, },
// Удалить из избранного // Удаление из избранного
removeFavorite(mediaId: string) { removeFavorite(mediaId: string) {
return api.delete(`/favorites/${mediaId}`); return neoApi.delete(`/api/v1/favorites/${mediaId}`);
}, },
// Проверить есть ли в избранном // Проверка, добавлен ли в избранное
checkFavorite(mediaId: string) { checkFavorite(mediaId: string) {
return api.get(`/favorites/check/${mediaId}`); return neoApi.get(`/api/v1/favorites/check/${mediaId}`);
} }
}; };

View File

@@ -29,32 +29,26 @@ export async function connectToDatabase() {
return { db, client }; return { db, client };
} }
// Инициализация MongoDB
export async function initMongoDB() { export async function initMongoDB() {
try { try {
const { db } = await connectToDatabase(); const { db } = await connectToDatabase();
// Создаем уникальный индекс для избранного
await db.collection('favorites').createIndex( await db.collection('favorites').createIndex(
{ userId: 1, mediaId: 1, mediaType: 1 }, { userId: 1, mediaId: 1, mediaType: 1 },
{ unique: true } { unique: true }
); );
console.log('MongoDB initialized successfully');
} catch (error) { } catch (error) {
console.error('Error initializing MongoDB:', error); console.error('Error initializing MongoDB:', error);
throw error; throw error;
} }
} }
// Функция для сброса и создания индексов
export async function resetIndexes() { export async function resetIndexes() {
const { db } = await connectToDatabase(); const { db } = await connectToDatabase();
// Удаляем все индексы из коллекции favorites
await db.collection('favorites').dropIndexes(); await db.collection('favorites').dropIndexes();
// Создаем новый правильный индекс
await db.collection('favorites').createIndex( await db.collection('favorites').createIndex(
{ userId: 1, mediaId: 1, mediaType: 1 }, { userId: 1, mediaId: 1, mediaType: 1 },
{ unique: true } { unique: true }

View File

@@ -1,18 +1,25 @@
import axios from 'axios'; import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://neomovies-test-api.vercel.app';
// Создание экземпляра Axios с базовыми настройками
export const neoApi = axios.create({ export const neoApi = axios.create({
baseURL: API_URL, baseURL: API_URL,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
timeout: 30000 // Увеличиваем таймаут до 30 секунд timeout: 30000
}); });
// Добавляем перехватчики запросов // Перехватчик запросов
neoApi.interceptors.request.use( neoApi.interceptors.request.use(
(config) => { (config) => {
// Получение токена из localStorage или другого хранилища
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Логика для пагинации
if (config.params?.page) { if (config.params?.page) {
const page = parseInt(config.params.page); const page = parseInt(config.params.page);
if (isNaN(page) || page < 1) { if (isNaN(page) || page < 1) {
@@ -27,29 +34,34 @@ neoApi.interceptors.request.use(
} }
); );
// Добавляем перехватчики ответов // Перехватчик ответов
neoApi.interceptors.response.use( neoApi.interceptors.response.use(
(response) => { (response) => {
if (response.data && response.data.success && response.data.data !== undefined) {
response.data = response.data.data;
}
return response; return response;
}, },
(error) => { (error) => {
console.error('❌ Response Error:', { console.error('❌ Response Error:', {
status: error.response?.status, status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url, url: error.config?.url,
method: error.config?.method, method: error.config?.method,
message: error.message message: error.message,
data: error.response?.data
}); });
return Promise.reject(error); return Promise.reject(error);
} }
); );
// Функция для получения URL изображения
export const getImageUrl = (path: string | null, size: string = 'w500'): string => { export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
if (!path) return '/images/placeholder.jpg'; if (!path) return '/images/placeholder.jpg';
// Извлекаем только ID изображения из полного пути if (path.startsWith('http')) {
const imageId = path.split('/').pop(); return path;
if (!imageId) return '/images/placeholder.jpg'; }
return `${API_URL}/images/${size}/${imageId}`; const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
}; };
export interface Genre { export interface Genre {
@@ -80,10 +92,44 @@ export interface MovieResponse {
total_results: number; total_results: number;
} }
export interface TorrentResult {
title: string;
tracker: string;
size: string;
seeders: number;
peers: number;
leechers: number;
quality: string;
voice?: string[];
types?: string[];
seasons?: number[];
category: string;
magnet: string;
torrent_link?: string;
details?: string;
publish_date: string;
added_date?: string;
source: string;
}
export interface TorrentSearchResponse {
query: string;
results: TorrentResult[];
total: number;
}
export interface AvailableSeasonsResponse {
title: string;
originalTitle: string;
year: string;
seasons: number[];
total: number;
}
export const searchAPI = { export const searchAPI = {
// Поиск фильмов // Поиск фильмов
searchMovies(query: string, page = 1) { searchMovies(query: string, page = 1) {
return neoApi.get<MovieResponse>('/movies/search', { return neoApi.get<MovieResponse>('/api/v1/movies/search', {
params: { params: {
query, query,
page page
@@ -94,7 +140,7 @@ export const searchAPI = {
// Поиск сериалов // Поиск сериалов
searchTV(query: string, page = 1) { searchTV(query: string, page = 1) {
return neoApi.get<MovieResponse>('/tv/search', { return neoApi.get<MovieResponse>('/api/v1/tv/search', {
params: { params: {
query, query,
page page
@@ -103,46 +149,19 @@ export const searchAPI = {
}); });
}, },
// Мультипоиск (фильмы и сериалы) // Мультипоиск (фильмы и сериалы) - новый эндпоинт
async multiSearch(query: string, page = 1) { async multiSearch(query: string, page = 1) {
// Запускаем параллельные запросы к фильмам и сериалам
try { try {
const [moviesResponse, tvResponse] = await Promise.all([ // Используем новый эндпоинт Go API
this.searchMovies(query, page), const response = await neoApi.get<MovieResponse>('/search/multi', {
this.searchTV(query, page) params: {
]); query,
page
},
timeout: 30000
});
// Объединяем результаты return response;
const moviesData = moviesResponse.data;
const tvData = tvResponse.data;
// Метаданные для пагинации
const totalResults = (moviesData.total_results || 0) + (tvData.total_results || 0);
const totalPages = Math.max(moviesData.total_pages || 0, tvData.total_pages || 0);
// Добавляем информацию о типе контента
const moviesWithType = (moviesData.results || []).map(movie => ({
...movie,
media_type: 'movie'
}));
const tvWithType = (tvData.results || []).map(show => ({
...show,
media_type: 'tv'
}));
// Объединяем и сортируем по популярности
const combinedResults = [...moviesWithType, ...tvWithType]
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
return {
data: {
page: parseInt(String(page)),
results: combinedResults,
total_pages: totalPages,
total_results: totalResults
}
};
} catch (error) { } catch (error) {
console.error('Error in multiSearch:', error); console.error('Error in multiSearch:', error);
throw error; throw error;
@@ -153,7 +172,7 @@ export const searchAPI = {
export const moviesAPI = { export const moviesAPI = {
// Получение популярных фильмов // Получение популярных фильмов
getPopular(page = 1) { getPopular(page = 1) {
return neoApi.get<MovieResponse>('/movies/popular', { return neoApi.get<MovieResponse>('/api/v1/movies/popular', {
params: { page }, params: { page },
timeout: 30000 timeout: 30000
}); });
@@ -161,7 +180,7 @@ export const moviesAPI = {
// Получение фильмов с высоким рейтингом // Получение фильмов с высоким рейтингом
getTopRated(page = 1) { getTopRated(page = 1) {
return neoApi.get<MovieResponse>('/movies/top_rated', { return neoApi.get<MovieResponse>('/api/v1/movies/top-rated', {
params: { page }, params: { page },
timeout: 30000 timeout: 30000
}); });
@@ -169,7 +188,15 @@ export const moviesAPI = {
// Получение новинок // Получение новинок
getNowPlaying(page = 1) { getNowPlaying(page = 1) {
return neoApi.get<MovieResponse>('/movies/now_playing', { return neoApi.get<MovieResponse>('/api/v1/movies/now-playing', {
params: { page },
timeout: 30000
});
},
// Получение предстоящих фильмов
getUpcoming(page = 1) {
return neoApi.get<MovieResponse>('/api/v1/movies/upcoming', {
params: { page }, params: { page },
timeout: 30000 timeout: 30000
}); });
@@ -177,12 +204,12 @@ export const moviesAPI = {
// Получение данных о фильме по его ID // Получение данных о фильме по его ID
getMovie(id: string | number) { getMovie(id: string | number) {
return neoApi.get(`/movies/${id}`, { timeout: 30000 }); return neoApi.get(`/api/v1/movies/${id}`, { timeout: 30000 });
}, },
// Поиск фильмов // Поиск фильмов
searchMovies(query: string, page = 1) { searchMovies(query: string, page = 1) {
return neoApi.get<MovieResponse>('/movies/search', { return neoApi.get<MovieResponse>('/api/v1/movies/search', {
params: { params: {
query, query,
page page
@@ -191,16 +218,40 @@ export const moviesAPI = {
}); });
}, },
// Получение IMDB ID // Получение IMDB и других external ids
getImdbId(id: string | number) { getExternalIds(id: string | number) {
return neoApi.get(`/movies/${id}/external_ids`, { timeout: 30000 }).then(res => res.data.imdb_id); return neoApi.get(`/api/v1/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data);
} }
}; };
export const tvShowsAPI = { export const tvShowsAPI = {
// Получение популярных сериалов // Получение популярных сериалов
getPopular(page = 1) { getPopular(page = 1) {
return neoApi.get('/tv/popular', { return neoApi.get('/api/v1/tv/popular', {
params: { page },
timeout: 30000
});
},
// Получение сериалов с высоким рейтингом
getTopRated(page = 1) {
return neoApi.get('/api/v1/tv/top-rated', {
params: { page },
timeout: 30000
});
},
// Получение сериалов в эфире
getOnTheAir(page = 1) {
return neoApi.get('/api/v1/tv/on-the-air', {
params: { page },
timeout: 30000
});
},
// Получение сериалов, которые выходят сегодня
getAiringToday(page = 1) {
return neoApi.get('/api/v1/tv/airing-today', {
params: { page }, params: { page },
timeout: 30000 timeout: 30000
}); });
@@ -208,12 +259,12 @@ export const tvShowsAPI = {
// Получение данных о сериале по его ID // Получение данных о сериале по его ID
getTVShow(id: string | number) { getTVShow(id: string | number) {
return neoApi.get(`/tv/${id}`, { timeout: 30000 }); return neoApi.get(`/api/v1/tv/${id}`, { timeout: 30000 });
}, },
// Поиск сериалов // Поиск сериалов
searchTVShows(query: string, page = 1) { searchTVShows(query: string, page = 1) {
return neoApi.get('/tv/search', { return neoApi.get('/api/v1/tv/search', {
params: { params: {
query, query,
page page
@@ -222,8 +273,101 @@ export const tvShowsAPI = {
}); });
}, },
// Получение IMDB ID // Получение IMDB и других external ids
getImdbId(id: string | number) { getExternalIds(id: string | number) {
return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id); return neoApi.get(`/api/v1/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data);
}
};
export const torrentsAPI = {
// Поиск торрентов по IMDB ID
searchTorrents(imdbId: string, type: 'movie' | 'tv', options?: {
season?: number;
quality?: string;
minQuality?: string;
maxQuality?: string;
excludeQualities?: string;
hdr?: boolean;
hevc?: boolean;
sortBy?: string;
sortOrder?: string;
groupByQuality?: boolean;
groupBySeason?: boolean;
}) {
const params: any = { type };
if (options) {
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) {
if (key === 'excludeQualities' && Array.isArray(value)) {
params[key] = value.join(',');
} else {
params[key] = value;
}
}
});
}
return neoApi.get<TorrentSearchResponse>(`/api/v1/torrents/search/${imdbId}`, {
params,
timeout: 30000
});
},
// Получение доступных сезонов для сериала
getAvailableSeasons(title: string, originalTitle?: string, year?: string) {
const params: any = { title };
if (originalTitle) params.originalTitle = originalTitle;
if (year) params.year = year;
return neoApi.get<AvailableSeasonsResponse>('/api/v1/torrents/seasons', {
params,
timeout: 30000
});
},
// Универсальный поиск торрентов по запросу
searchByQuery(query: string, type: 'movie' | 'tv' | 'anime' = 'movie', year?: string) {
const params: any = { query, type };
if (year) params.year = year;
return neoApi.get<TorrentSearchResponse>('/api/v1/torrents/search', {
params,
timeout: 30000
});
}
};
export const categoriesAPI = {
// Получение всех категорий
getCategories() {
return neoApi.get<{ categories: Category[] }>('/api/v1/categories');
},
// Получение категории по ID
getCategory(id: number) {
return neoApi.get<Category>(`/api/v1/categories/${id}`);
},
// Получение фильмов по категории
getMoviesByCategory(categoryId: number, page = 1) {
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/movies`, {
params: { page }
});
},
// Получение сериалов по категории
getTVShowsByCategory(categoryId: number, page = 1) {
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/tv`, {
params: { page }
});
}
};
// Новый API-клиент для работы с аутентификацией и профилем
export const authAPI = {
// Новый метод для удаления аккаунта
deleteAccount() {
return neoApi.delete('/api/v1/profile');
} }
}; };

View File

@@ -1,28 +1,30 @@
import { api } from './api'; import { neoApi } from './neoApi';
export interface Reaction { export interface Reaction {
_id: string; type: 'like' | 'dislike';
userId: string;
mediaId: string; mediaId: string;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
type: 'fire' | 'nice' | 'think' | 'bore' | 'shit';
createdAt: string;
} }
export const reactionsAPI = { export const reactionsAPI = {
// [PUBLIC] Получить счетчики для всех типов реакций // Получение счетчиков реакций
getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record<string, number> }> { getReactionCounts(mediaType: string, mediaId: string) {
return api.get(`/reactions/${mediaType}/${mediaId}/counts`); return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/counts`);
}, },
// [AUTH] Получить реакцию пользователя для медиа // Получение моей реакции
getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> { getMyReaction(mediaType: string, mediaId: string) {
return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`); return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/my-reaction`);
}, },
// [AUTH] Установить/обновить/удалить реакцию // Установка реакции
setReaction(mediaType: string, mediaId: string, type: Reaction['type']): Promise<{ data: Reaction }> { setReaction(mediaType: string, mediaId: string, type: 'like' | 'dislike') {
const fullMediaId = `${mediaType}_${mediaId}`; const fullMediaId = `${mediaType}_${mediaId}`;
return api.post('/reactions', { mediaId: fullMediaId, type }); return neoApi.post('/api/v1/reactions', { mediaId: fullMediaId, type });
}, },
// Удаление реакции
removeReaction(mediaType: string, mediaId: string) {
return neoApi.delete(`/api/v1/reactions/${mediaType}/${mediaId}`);
}
}; };

View File

@@ -35,7 +35,6 @@ const FavoriteSchema: Schema = new Schema({
timestamps: true, timestamps: true,
}); });
// Ensure a user can't favorite the same item multiple times
FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true }); FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true });
export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema); export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema);

View File

@@ -40,7 +40,6 @@ const userSchema = new Schema<IUser>({
timestamps: true, timestamps: true,
}); });
// Не включаем пароль в запросы по умолчанию
userSchema.set('toJSON', { userSchema.set('toJSON', {
transform: function(doc, ret) { transform: function(doc, ret) {
delete ret.password; delete ret.password;
@@ -48,7 +47,6 @@ userSchema.set('toJSON', {
} }
}); });
// Хэшируем пароль перед сохранением
userSchema.pre('save', async function(next) { userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next(); if (!this.isModified('password')) return next();
@@ -61,7 +59,6 @@ userSchema.pre('save', async function(next) {
} }
}); });
// Метод для проверки пароля
userSchema.methods.comparePassword = async function(candidatePassword: string) { userSchema.methods.comparePassword = async function(candidatePassword: string) {
try { try {
return await bcrypt.compare(candidatePassword, this.password); return await bcrypt.compare(candidatePassword, this.password);