From a1f1deea13bfb32cd366d931b73362378ae1ada2 Mon Sep 17 00:00:00 2001 From: foxixus Date: Thu, 7 Aug 2025 18:33:28 +0000 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D0=90=D0=9F=D0=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + README.md | 94 +- next.config.js | 10 +- package-lock.json | 839 +++++++++++++++++- package.json | 2 + public/robots.txt | 9 + public/sitemap-0.xml | 13 + public/sitemap.xml | 4 + src/app/admin/login/AdminLoginClient.tsx | 6 +- src/app/api/movies/[id]/external-ids/route.ts | 45 - src/app/api/movies/[id]/route.ts | 34 - src/app/api/movies/popular/route.ts | 37 - src/app/api/movies/search/route.ts | 31 - src/app/api/movies/sync/route.ts | 15 - src/app/api/movies/top-rated/route.ts | 19 - src/app/api/movies/upcoming/route.ts | 19 - src/app/categories/[id]/page.tsx | 8 +- src/app/categories/page.tsx | 12 +- src/app/globals.css | 8 + src/app/movie/[id]/MovieContent.tsx | 15 +- src/app/movie/[id]/MoviePage.tsx | 2 +- src/app/movie/[id]/page.tsx | 9 +- src/app/page.tsx | 18 +- src/app/search/SearchClient.tsx | 20 +- src/app/tv/[id]/TVContent.tsx | 21 +- src/app/tv/[id]/TVPage.tsx | 2 +- src/app/tv/[id]/page.tsx | 8 +- src/components/CategoryCard.tsx | 2 +- src/components/MoviePlayer.tsx | 62 +- src/components/TorrentSelector.tsx | 467 ++++++---- src/components/ui/dialog.tsx | 122 +++ src/components/ui/select.tsx | 160 ++++ src/hooks/useAuth.ts | 60 +- src/hooks/useMovies.ts | 9 +- src/hooks/useSearch.ts | 8 +- src/hooks/useTMDBMovies.ts | 181 ---- src/hooks/useUser.ts | 80 +- src/lib/api.ts | 262 ------ src/lib/authApi.ts | 23 +- src/lib/favoritesApi.ts | 23 +- src/lib/mongodb.ts | 6 - src/lib/neoApi.ts | 270 ++++-- src/lib/reactionsApi.ts | 30 +- src/models/Favorite.ts | 1 - src/models/User.ts | 3 - 45 files changed, 1955 insertions(+), 1119 deletions(-) create mode 100644 .env.example create mode 100644 public/robots.txt create mode 100644 public/sitemap-0.xml create mode 100644 public/sitemap.xml delete mode 100644 src/app/api/movies/[id]/external-ids/route.ts delete mode 100644 src/app/api/movies/[id]/route.ts delete mode 100644 src/app/api/movies/popular/route.ts delete mode 100644 src/app/api/movies/search/route.ts delete mode 100644 src/app/api/movies/sync/route.ts delete mode 100644 src/app/api/movies/top-rated/route.ts delete mode 100644 src/app/api/movies/upcoming/route.ts create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/select.tsx delete mode 100644 src/hooks/useTMDBMovies.ts delete mode 100644 src/lib/api.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ecff17 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# API URL для нового Go API +NEXT_PUBLIC_API_URL=https://api.neomovies.ru + +# Для локальной разработки используйте: +# NEXT_PUBLIC_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/README.md b/README.md index 28129d8..2aa18e4 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,47 @@ -# 🎬 Neo Movies +# NeoMovies Web 🎬 -
- Neo Movies Logo -

Современный онлайн-сервис с удобным интерфейсом

-
+> Современный веб-интерфейс для поиска и просмотра фильмов и сериалов -## 📋 О проекте +## 🚀 Особенности -Neo Movies - это современная веб-платформа построенная с использованием передовых технологий. Проект предлагает удобный интерфейс, быструю навигацию и множество функций для комфортного просмотра информации об фильмах и сералах а также стороние плееры предоставляемые видео-балансерами. - -### ✨ Основные возможности - -- 🎥 Два встроенных видеоплеера на выбор (Alloha, Lumex) -- 🔍 Умный поиск по фильмам -- 📱 Адаптивный дизайн для всех устройств -- 🌙 Темная тема -- 👤 Система авторизации и профили пользователей -- ❤️ Возможность добавлять фильмы в избранное -- ⚡ Быстрая загрузка и оптимизированная производительность +- 🎭 **TMDB интеграция** - полная информация о фильмах и сериалах +- 🔍 **Умный поиск** - поиск по названию, актерам, жанрам +- 🎬 **Встроенные плееры** - просмотр через Alloha и Lumex +- 🧲 **Торрент интеграция** - поиск раздач по IMDB ID +- ⭐ **Система избранного** - сохраняйте любимые фильмы +- 🎨 **Современный UI** - адаптивный дизайн с темной темой +- 📱 **Мобильная версия** - оптимизировано для всех устройств +- 🔐 **JWT аутентификация** - безопасная авторизация +- 📧 **Email верификация** - подтверждение аккаунта ## 🛠 Технологии -- **Frontend:** - - Next.js 13+ (App Router) - - React 18 - - TypeScript - - Styled Components - - JWT-based authentication (custom) +- **Frontend**: Next.js 15, React 19, TypeScript +- **Styling**: Tailwind CSS, Radix UI +- **State Management**: Redux Toolkit +- **API**: Go API (neomovies-api) +- **Database**: MongoDB +- **Authentication**: JWT +- **Deployment**: Vercel -- **Backend:** - - Node.js + Express (neomovies-api) - - MongoDB (native driver) +## 📦 Установка -- **Дополнительно:** - - ESLint - - Prettier - - Git - - npm - -## Начало работы - -1. Клонируйте репозиторий: +1. **Клонируйте репозиторий:** ```bash -git clone https://gitlab.com/foxixus/neomovies.git -cd neomovies +git clone https://github.com/Ernous/neomovies-web.git +cd neomovies-web ``` -2. Установите зависимости: +2. **Установите зависимости:** ```bash npm install ``` 3. Создайте файл `.env` и добавьте следующие переменные: ```env -NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app -NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key -NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token +NEXT_PUBLIC_API_URL=https://api.neomovies.ru ``` - 4. **Запустите проект:** ```bash # Режим разработки @@ -72,33 +55,37 @@ npm start ## API (neomovies-api) -Приложение использует отдельный API сервер. API предоставляет следующие возможности: +Приложение использует отдельный Go API сервер. API предоставляет следующие возможности: -- Поиск фильмов и сериалов +- Поиск фильмов и сериалов через TMDB - Получение детальной информации о фильме/сериале +- Поиск торрентов по IMDB ID с парсингом сезонов из названий +- Система избранного и реакций +- JWT аутентификация с email верификацией - Оптимизированная загрузка изображений - Кэширование запросов -### Gmail App Password -1. Включите двухфакторную аутентификацию в аккаунте Google -2. Перейдите в настройки безопасности -3. Создайте пароль приложения -4. Используйте этот пароль в GMAIL_APP_PASSWORD +### Особенности торрент-поиска -Backend `.env` пример смотрите в репозитории [neomovies-api](https://gitlab.com/foxixus/neomovies-api). +Новый API автоматически парсит сезоны из названий торрентов, что позволяет: +- Получать реальные доступные сезоны, а не только из TMDB +- Находить раздачи даже если нумерация сезонов отличается от официальной +- Группировать торренты по сезонам для удобного выбора + +Backend `.env` пример смотрите в репозитории [neomovies-api](https://github.com/Ernous/neomovies-api). --- ## Структура проекта ``` -neomovies/ +neomovies-web/ ├── src/ │ ├── app/ # App Router pages │ ├── components/ # React компоненты │ ├── hooks/ # React хуки │ ├── lib/ # Утилиты и API -│ ├── models/ # MongoDB модели +│ ├── types/ # TypeScript типы │ └── styles/ # Глобальные стили ├── public/ # Статические файлы └── package.json @@ -108,6 +95,7 @@ neomovies/ ## 👥 Авторы - **Frontend Developer** - [Foxix](https://gitlab.com/foxixus) +- **Backend Developer** - [Ernous](https://github.com/Ernous) ## 📄 Лицензия @@ -126,7 +114,7 @@ neomovies/ ## Благодарности - [TMDB](https://www.themoviedb.org/) за предоставление API -- [Vercel](https://vercel.com/) за хостинг API +- [Vercel](https://vercel.com/) за хостинг ## 📞 Контакты diff --git a/next.config.js b/next.config.js index 6e1ea18..2b11351 100644 --- a/next.config.js +++ b/next.config.js @@ -28,10 +28,16 @@ const nextConfig = { port: '3000', pathname: '/images/**', }, - // Продакшен на Vercel + // Наш API прокси для изображений { protocol: 'https', - hostname: 'neomovies-api.vercel.app', + hostname: 'neomovies-test-api.vercel.app', + pathname: '/api/v1/images/**', + }, + // Продакшен на Vercel (старый) + { + protocol: 'https', + hostname: 'neomovies-test-api.vercel.app', pathname: '/images/**', }, { diff --git a/package-lock.json b/package-lock.json index 4c26fee..d42c89d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "neo-movies-web", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-slot": "^1.2.3", "@reduxjs/toolkit": "^2.5.0", "@tabler/icons-react": "^3.26.0", "@types/bcrypt": "^5.0.2", @@ -20,7 +23,9 @@ "axios": "^1.7.9", "bcrypt": "^5.1.1", "bcryptjs": "^2.4.3", + "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", + "clsx": "^2.1.1", "framer-motion": "^11.15.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", @@ -28,15 +33,18 @@ "mongodb": "^6.12.0", "mongoose": "^8.9.2", "next": "15.1.2", + "next-seo": "^6.8.0", "next-themes": "^0.4.6", "nodemailer": "^6.9.16", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.4.1", + "react-icons": "^5.5.0", "react-redux": "^9.2.0", "resend": "^4.0.1", "styled-components": "^6.1.13", - "tailwind": "^4.0.0" + "tailwind": "^4.0.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -45,6 +53,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.1.2", + "next-sitemap": "^4.2.3", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" @@ -72,6 +81,13 @@ "regenerator-runtime": "^0.12.0" } }, + "node_modules/@corex/deepmerge": { + "version": "4.0.43", + "resolved": "https://registry.npmjs.org/@corex/deepmerge/-/deepmerge-4.0.43.tgz", + "integrity": "sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -280,6 +296,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", + "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", + "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1056,6 +1110,562 @@ "node": ">=14" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@react-email/render": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", @@ -1290,7 +1900,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2128,6 +2738,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2762,6 +3384,18 @@ "node": ">=10" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -2774,6 +3408,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -3260,6 +3903,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4652,6 +5301,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", @@ -6378,6 +7036,52 @@ } } }, + "node_modules/next-seo": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.8.0.tgz", + "integrity": "sha512-zcxaV67PFXCSf8e6SXxbxPaOTgc8St/esxfsYXfQXMM24UESUVSXFm7f2A9HMkAwa0Gqn4s64HxYZAGfdF4Vhg==", + "license": "MIT", + "peerDependencies": { + "next": "^8.1.1-canary.54 || >=9.0.0", + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/next-sitemap": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz", + "integrity": "sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==", + "dev": true, + "funding": [ + { + "url": "https://github.com/iamvishnusankar/next-sitemap.git" + } + ], + "license": "MIT", + "dependencies": { + "@corex/deepmerge": "^4.0.43", + "@next/env": "^13.4.3", + "fast-glob": "^3.2.12", + "minimist": "^1.2.8" + }, + "bin": { + "next-sitemap": "bin/next-sitemap.mjs", + "next-sitemap-cjs": "bin/next-sitemap.cjs" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "next": "*" + } + }, + "node_modules/next-sitemap/node_modules/@next/env": { + "version": "13.5.11", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.11.tgz", + "integrity": "sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==", + "dev": true, + "license": "MIT" + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7223,6 +7927,15 @@ "react-dom": ">=16" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7267,6 +7980,75 @@ } } }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -8416,6 +9198,16 @@ "ws": "6.2.0" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwind/node_modules/ajv": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", @@ -8919,6 +9711,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", diff --git a/package.json b/package.json index e97a2b7..d371175 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@reduxjs/toolkit": "^2.5.0", "@tabler/icons-react": "^3.26.0", diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..f029c59 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,9 @@ +# * +User-agent: * +Allow: / + +# Host +Host: https://neomovies.ru + +# Sitemaps +Sitemap: https://neomovies.ru/sitemap.xml diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml new file mode 100644 index 0000000..58a8925 --- /dev/null +++ b/public/sitemap-0.xml @@ -0,0 +1,13 @@ + + +https://neomovies.ru/admin/login2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/categories2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/favorites2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/login2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/profile2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/search2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/settings2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/terms2025-08-07T09:55:31.360Zdaily0.7 +https://neomovies.ru/verify2025-08-07T09:55:31.360Zdaily0.7 + \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..2ba42a7 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,4 @@ + + +https://neomovies.ru/sitemap-0.xml + \ No newline at end of file diff --git a/src/app/admin/login/AdminLoginClient.tsx b/src/app/admin/login/AdminLoginClient.tsx index b8b7dee..9e03a4f 100644 --- a/src/app/admin/login/AdminLoginClient.tsx +++ b/src/app/admin/login/AdminLoginClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { api } from '@/lib/api'; +import { neoApi } from '@/lib/neoApi'; import { AxiosError } from 'axios'; import { useRouter } from 'next/navigation'; import styled from 'styled-components'; @@ -92,12 +92,12 @@ export default function AdminLoginClient() { setIsLoading(true); try { - const response = await api.post('/auth/login', { + const response = await neoApi.post('/api/v1/auth/login', { email, password, }); - const { token, user } = response.data; + const { token, user } = response.data.data || response.data; if (user?.role !== 'admin') { setError('У вас нет прав администратора.'); diff --git a/src/app/api/movies/[id]/external-ids/route.ts b/src/app/api/movies/[id]/external-ids/route.ts deleted file mode 100644 index 761dd89..0000000 --- a/src/app/api/movies/[id]/external-ids/route.ts +++ /dev/null @@ -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': '*' - } - } - ); - } -} diff --git a/src/app/api/movies/[id]/route.ts b/src/app/api/movies/[id]/route.ts deleted file mode 100644 index 358e538..0000000 --- a/src/app/api/movies/[id]/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/movies/popular/route.ts b/src/app/api/movies/popular/route.ts deleted file mode 100644 index b311bac..0000000 --- a/src/app/api/movies/popular/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/movies/search/route.ts b/src/app/api/movies/search/route.ts deleted file mode 100644 index ca2de3a..0000000 --- a/src/app/api/movies/search/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/movies/sync/route.ts b/src/app/api/movies/sync/route.ts deleted file mode 100644 index 193176f..0000000 --- a/src/app/api/movies/sync/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/movies/top-rated/route.ts b/src/app/api/movies/top-rated/route.ts deleted file mode 100644 index 01c1bf6..0000000 --- a/src/app/api/movies/top-rated/route.ts +++ /dev/null @@ -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); -} diff --git a/src/app/api/movies/upcoming/route.ts b/src/app/api/movies/upcoming/route.ts deleted file mode 100644 index c9315df..0000000 --- a/src/app/api/movies/upcoming/route.ts +++ /dev/null @@ -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); -} diff --git a/src/app/categories/[id]/page.tsx b/src/app/categories/[id]/page.tsx index 206a593..265c0ab 100644 --- a/src/app/categories/[id]/page.tsx +++ b/src/app/categories/[id]/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { categoriesAPI, Movie, TVShow } from '@/lib/api'; +import { categoriesAPI, Movie } from '@/lib/neoApi'; import MovieCard from '@/components/MovieCard'; import { ArrowLeft, Loader2 } from 'lucide-react'; @@ -60,10 +60,10 @@ function CategoryPage() { response = await categoriesAPI.getTVShowsByCategory(categoryId, page); const hasTvShows = response.data.results.length > 0; if (page === 1) setTvShowsAvailable(hasTvShows); - const transformedShows = response.data.results.map((show: TVShow) => ({ + const transformedShows = response.data.results.map((show: any) => ({ ...show, - title: show.name, - release_date: show.first_air_date, + title: show.name || show.title, + release_date: show.first_air_date || show.release_date, })); setItems(transformedShows); setTotalPages(response.data.total_pages); diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index b28dfd4..6a3bf4b 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { categoriesAPI } from '@/lib/api'; -import { Category } from '@/lib/api'; +import { categoriesAPI, Category } from '@/lib/neoApi'; import CategoryCard from '@/components/CategoryCard'; interface CategoryWithBackground extends Category { @@ -14,14 +13,12 @@ function CategoriesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Загрузка категорий и фоновых изображений для них useEffect(() => { async function fetchCategoriesAndBackgrounds() { setError(null); setLoading(true); try { - // Получаем список категорий const categoriesResponse = await categoriesAPI.getCategories(); if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) { @@ -30,14 +27,11 @@ function CategoriesPage() { return; } - // Добавляем фоновые изображения для каждой категории const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all( categoriesResponse.data.categories.map(async (category: Category) => { try { - // Сначала пробуем получить фильм для фона const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1); - // Проверяем, есть ли фильмы в данной категории if (moviesResponse.data.results && moviesResponse.data.results.length > 0) { const backgroundUrl = moviesResponse.data.results[0].backdrop_path || moviesResponse.data.results[0].poster_path; @@ -47,7 +41,6 @@ function CategoriesPage() { backgroundUrl }; } else { - // Если фильмов нет, пробуем получить сериалы const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1); if (tvResponse.data.results && tvResponse.data.results.length > 0) { @@ -61,14 +54,13 @@ function CategoriesPage() { } } - // Если ни фильмов, ни сериалов не найдено return { ...category, backgroundUrl: undefined }; } catch (error) { console.error(`Error fetching background for category ${category.id}:`, error); - return category; // Возвращаем категорию без фона в случае ошибки + return category; } }) ); diff --git a/src/app/globals.css b/src/app/globals.css index 8b02691..699d422 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -49,3 +49,11 @@ body { [data-nextjs-toast-wrapper] { display: none !important; } + +/* Utility classes */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx index 0958406..d0b7cde 100644 --- a/src/app/movie/[id]/MovieContent.tsx +++ b/src/app/movie/[id]/MovieContent.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import Image from 'next/image'; import { moviesAPI } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi'; -import type { MovieDetails } from '@/lib/api'; +import type { MovieDetails } from '@/lib/neoApi'; import MoviePlayer from '@/components/MoviePlayer'; import TorrentSelector from '@/components/TorrentSelector'; import FavoriteButton from '@/components/FavoriteButton'; @@ -20,23 +20,25 @@ interface MovieContentProps { export default function MovieContent({ movieId, initialMovie }: MovieContentProps) { const [movie] = useState(initialMovie); + const [externalIds, setExternalIds] = useState(null); const [imdbId, setImdbId] = useState(null); const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false); const [isControlsVisible, setIsControlsVisible] = useState(false); const controlsTimeoutRef = useRef(null); useEffect(() => { - const fetchImdbId = async () => { + const fetchExternalIds = async () => { try { - const { data } = await moviesAPI.getMovie(movieId); + const data = await moviesAPI.getExternalIds(movieId); + setExternalIds(data); if (data?.imdb_id) { setImdbId(data.imdb_id); } } catch (err) { - console.error('Error fetching IMDb ID:', err); + console.error('Error fetching external ids:', err); } }; - fetchImdbId(); + fetchExternalIds(); }, [movieId]); const showControls = () => { @@ -182,6 +184,9 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp )} diff --git a/src/app/movie/[id]/MoviePage.tsx b/src/app/movie/[id]/MoviePage.tsx index 600db84..614f036 100644 --- a/src/app/movie/[id]/MoviePage.tsx +++ b/src/app/movie/[id]/MoviePage.tsx @@ -2,7 +2,7 @@ import PageLayout from '@/components/PageLayout'; import MovieContent from './MovieContent'; -import type { MovieDetails } from '@/lib/api'; +import type { MovieDetails } from '@/lib/neoApi'; interface MoviePageProps { movieId: string; diff --git a/src/app/movie/[id]/page.tsx b/src/app/movie/[id]/page.tsx index 3cec36b..f1d6573 100644 --- a/src/app/movie/[id]/page.tsx +++ b/src/app/movie/[id]/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { moviesAPI } from '@/lib/api'; +import { moviesAPI } from '@/lib/neoApi'; import MoviePage from '@/app/movie/[id]/MoviePage'; interface PageProps { @@ -8,19 +8,13 @@ interface PageProps { }; } -// Генерация метаданных для страницы export async function generateMetadata(props: Promise): Promise { const { params } = await props; - // В Next.js 14, нужно сначала получить данные фильма, - // а затем использовать их для метаданных try { - // Получаем id для использования в запросе const movieId = params.id; - // Запрашиваем данные фильма const { data: movie } = await moviesAPI.getMovie(movieId); - // Создаем метаданные на основе полученных данных return { title: `${movie.title} - NeoMovies`, description: movie.overview, @@ -33,7 +27,6 @@ export async function generateMetadata(props: Promise): Promise + diff --git a/src/app/search/SearchClient.tsx b/src/app/search/SearchClient.tsx index 5735502..62bc604 100644 --- a/src/app/search/SearchClient.tsx +++ b/src/app/search/SearchClient.tsx @@ -2,30 +2,24 @@ import { useState, useEffect, FormEvent } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api'; +import { searchAPI } from '@/lib/neoApi'; +import type { Movie } from '@/lib/neoApi'; import MovieCard from '@/components/MovieCard'; export default function SearchClient() { const router = useRouter(); const searchParams = useSearchParams(); const [query, setQuery] = useState(searchParams.get('q') || ''); - const [results, setResults] = useState<(Movie | TVShow)[]>([]); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { const currentQuery = searchParams.get('q'); if (currentQuery) { setLoading(true); - Promise.all([ - moviesAPI.searchMovies(currentQuery), - tvAPI.searchShows(currentQuery), - ]) - .then(([movieResults, tvResults]) => { - const combined = [ - ...(movieResults.data.results || []), - ...(tvResults.data.results || []), - ]; - setResults(combined.sort((a, b) => b.vote_count - a.vote_count)); + searchAPI.multiSearch(currentQuery) + .then((response) => { + setResults(response.data.results || []); }) .catch((error) => { console.error('Search failed:', error); @@ -56,7 +50,7 @@ export default function SearchClient() {
{results.map((item) => ( ))} diff --git a/src/app/tv/[id]/TVContent.tsx b/src/app/tv/[id]/TVContent.tsx index 2a8075e..934f788 100644 --- a/src/app/tv/[id]/TVContent.tsx +++ b/src/app/tv/[id]/TVContent.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useRef } from 'react'; import Image from 'next/image'; -import { tvAPI } from '@/lib/api'; +import { tvShowsAPI } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi'; -import type { TVShowDetails } from '@/lib/api'; +import type { TVShowDetails } from '@/lib/neoApi'; import MoviePlayer from '@/components/MoviePlayer'; import TorrentSelector from '@/components/TorrentSelector'; import FavoriteButton from '@/components/FavoriteButton'; @@ -20,29 +20,28 @@ interface TVContentProps { export default function TVContent({ showId, initialShow }: TVContentProps) { const [show] = useState(initialShow); + const [externalIds, setExternalIds] = useState(null); const [imdbId, setImdbId] = useState(null); const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false); const [isControlsVisible, setIsControlsVisible] = useState(false); const controlsTimeoutRef = useRef(null); useEffect(() => { - const fetchImdbId = async () => { + const fetchExternalIds = async () => { try { - // Используем dedicated эндпоинт для получения IMDb ID - const { data } = await tvAPI.getImdbId(showId); + const data = await tvShowsAPI.getExternalIds(showId); + setExternalIds(data); if (data?.imdb_id) { setImdbId(data.imdb_id); } } catch (err) { - console.error('Error fetching IMDb ID:', err); + console.error('Error fetching external ids:', err); } }; - - // Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса if (initialShow.external_ids?.imdb_id) { setImdbId(initialShow.external_ids.imdb_id); } else { - fetchImdbId(); + fetchExternalIds(); } }, [showId, initialShow.external_ids]); @@ -185,7 +184,9 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
)} diff --git a/src/app/tv/[id]/TVPage.tsx b/src/app/tv/[id]/TVPage.tsx index ba2529f..706c209 100644 --- a/src/app/tv/[id]/TVPage.tsx +++ b/src/app/tv/[id]/TVPage.tsx @@ -2,7 +2,7 @@ import PageLayout from '@/components/PageLayout'; import TVContent from '@/app/tv/[id]/TVContent'; -import type { TVShowDetails } from '@/lib/api'; +import type { TVShowDetails } from '@/lib/neoApi'; interface TVPageProps { showId: string; diff --git a/src/app/tv/[id]/page.tsx b/src/app/tv/[id]/page.tsx index 098c805..775d024 100644 --- a/src/app/tv/[id]/page.tsx +++ b/src/app/tv/[id]/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { tvAPI } from '@/lib/api'; +import { tvShowsAPI } from '@/lib/neoApi'; import TVPage from '@/app/tv/[id]/TVPage'; export const dynamic = 'force-dynamic'; @@ -10,12 +10,11 @@ interface PageProps { }; } -// Генерация метаданных для страницы export async function generateMetadata(props: Promise): Promise { const { params } = await props; try { const showId = params.id; - const { data: show } = await tvAPI.getShow(showId); + const { data: show } = await tvShowsAPI.getTVShow(showId); return { title: `${show.name} - NeoMovies`, @@ -29,10 +28,9 @@ export async function generateMetadata(props: Promise): Promise { const fetchImdbId = async () => { - if (imdbId) return; + if (imdbId) { + setResolvedImdb(imdbId); + return; + } try { setLoading(true); setError(null); @@ -40,42 +43,43 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen = }, [id, imdbId]); useEffect(() => { - const loadPlayer = async () => { - if (!isInitialized || !resolvedImdb) return; - try { - setLoading(true); - setError(null); - const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex'; - const { data } = await api.get(basePath, { params: { imdb_id: resolvedImdb } }); - if (!data) throw new Error('Empty response'); + if (!isInitialized || !resolvedImdb) return; - let src: string | null = data.iframe || data.src || data.url || null; - if (!src && typeof data === 'string') { - const match = data.match(/]*src="([^"]+)"/i); - if (match && match[1]) src = match[1]; - } - if (!src) throw new Error('Invalid response format'); - setIframeSrc(src); - } catch (err) { - console.error(err); - setError('Не удалось загрузить плеер. Попробуйте позже.'); - } finally { - setLoading(false); - } - }; - loadPlayer(); + const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_BASE_URL) { + setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.'); + return; + } + + const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex'; + + // Формируем URL, где imdbId является частью пути + const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`; + + setIframeSrc(newIframeSrc); + setLoading(false); }, [resolvedImdb, isInitialized, settings.defaultPlayer]); const handleRetry = () => { setError(null); if (!resolvedImdb) { - // Re-fetch IMDb ID const event = new Event('fetchImdb'); window.dispatchEvent(event); } else { - // Re-load player - const event = new Event('loadPlayer'); - window.dispatchEvent(event); + setIframeSrc(null); + setLoading(true); + + const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_BASE_URL) { + setError('Переменная окружения NEXT_PUBLIC_API_URL не задана.'); + setLoading(false); + return; + } + + const playerEndpoint = settings.defaultPlayer === 'alloha' ? '/api/v1/players/alloha' : '/api/v1/players/lumex'; + const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`; + setIframeSrc(newIframeSrc); + setLoading(false); } }; diff --git a/src/components/TorrentSelector.tsx b/src/components/TorrentSelector.tsx index fff830f..83de707 100644 --- a/src/components/TorrentSelector.tsx +++ b/src/components/TorrentSelector.tsx @@ -1,205 +1,374 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Button } from '@/components/ui/button'; -import { Loader2, AlertTriangle, Copy, Check } from 'lucide-react'; - -interface Torrent { - magnet: string; - title?: string; - name?: string; - quality?: string; - seeders?: number; - size_gb?: number; -} - -interface GroupedTorrents { - [quality: string]: Torrent[]; -} - -interface SeasonGroupedTorrents { - [season: string]: GroupedTorrents; -} +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react'; +import { torrentsAPI, type TorrentResult } from '@/lib/neoApi'; interface TorrentSelectorProps { imdbId: string | null; type: 'movie' | 'tv'; - totalSeasons?: number; + title?: string; + originalTitle?: string; + year?: string; } -export default function TorrentSelector({ imdbId, type, totalSeasons }: TorrentSelectorProps) { - const [torrents, setTorrents] = useState(null); - const [selectedSeason, setSelectedSeason] = useState(type === 'movie' ? 1 : null); - const [selectedMagnet, setSelectedMagnet] = useState(null); +interface ParsedTorrent extends TorrentResult { + quality?: string; + season?: number; + sizeFormatted?: string; +} + +export default function TorrentSelector({ imdbId, type, title, originalTitle, year }: TorrentSelectorProps) { + const [torrents, setTorrents] = useState(null); + const [availableSeasons, setAvailableSeasons] = useState([]); + const [selectedSeason, setSelectedSeason] = useState(null); + const [selectedQuality, setSelectedQuality] = useState('all'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [isCopied, setIsCopied] = useState(false); + const [copiedMagnet, setCopiedMagnet] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); - // Для TV показов автоматически выбираем первый сезон - useEffect(() => { - if (type === 'tv' && totalSeasons && totalSeasons > 0 && !selectedSeason) { - setSelectedSeason(1); + const parseQuality = (title: string): string => { + const qualityRegex = /(2160p|4K|UHD|1080p|FHD|720p|HD|480p|SD|CAMRip|TS|TC|DVDRip|BDRip|WEBRip|HDTV)/i; + const match = title.match(qualityRegex); + if (match) { + const quality = match[1].toUpperCase(); + if (quality === 'UHD' || quality === '4K') return '4K'; + if (quality === 'FHD') return '1080P'; + if (quality === 'HD' && !title.match(/720p/i)) return '720P'; + if (quality === 'SD') return '480P'; + return quality; } - }, [type, totalSeasons, selectedSeason]); + return 'UNKNOWN'; + }; + + const parseSeason = (title: string): number | undefined => { + const seasonRegexes = [ + /(?:S|Season\s*)(\d+)/i, + /Сезон\s*(\d+)/i, + /Season\s*(\d+)/i, + /S(\d+)E\d+/i, + /(\d+)\s*сезон/i + ]; + + for (const regex of seasonRegexes) { + const match = title.match(regex); + if (match) { + return parseInt(match[1], 10); + } + } + return undefined; + }; + + const formatSize = (size: string | number): string => { + if (!size) return ''; + let sizeNum = Number(size); + if (!isNaN(sizeNum) && sizeNum > 0) { + if (sizeNum > 1024 * 1024 * 1024) { + return (sizeNum / (1024 * 1024 * 1024)).toFixed(2) + ' ГБ'; + } else if (sizeNum > 1024 * 1024) { + return (sizeNum / (1024 * 1024)).toFixed(2) + ' МБ'; + } else { + return (sizeNum / 1024).toFixed(2) + ' КБ'; + } + } + return size.toString(); + }; + + useEffect(() => { + if (type === 'tv' && torrents && torrents.length > 0) { + const seasons = [...new Set( + torrents + .map(t => t.season) + .filter(s => s !== undefined) + .sort((a, b) => a! - b!) + )] as number[]; + setAvailableSeasons(seasons); + } + }, [type, torrents]); useEffect(() => { if (!imdbId) return; - - // Для фильмов загружаем сразу - if (type === 'movie') { - fetchTorrents(); - } - // Для TV показов загружаем только когда выбран сезон - else if (type === 'tv' && selectedSeason) { - fetchTorrents(); - } - }, [imdbId, type, selectedSeason]); + fetchTorrents(); + }, [imdbId, type]); const fetchTorrents = async () => { setLoading(true); setError(null); - setSelectedMagnet(null); try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL; - if (!apiUrl) { - throw new Error('API URL не настроен'); - } - - let url = `${apiUrl}/torrents/search/${imdbId}?type=${type}`; - - if (type === 'tv' && selectedSeason) { - url += `&season=${selectedSeason}`; - } - - console.log('API URL:', url, 'IMDB:', imdbId, 'season:', selectedSeason); - const res = await fetch(url); - if (!res.ok) { - throw new Error('Failed to fetch torrents'); - } - const data = await res.json(); - if (data.total === 0) { + const response = await torrentsAPI.searchTorrents(imdbId!, type); + if (response.data.total === 0) { setError('Торренты не найдены.'); } else { - setTorrents(data.results as Torrent[]); + const parsedTorrents: ParsedTorrent[] = response.data.results.map(torrent => ({ + ...torrent, + quality: parseQuality(torrent.title || ''), + season: type === 'tv' ? parseSeason(torrent.title || '') : undefined, + sizeFormatted: formatSize(torrent.size || 0) + })); + setTorrents(parsedTorrents); } } catch (err) { - console.error(err); setError('Не удалось загрузить список торрентов.'); } finally { setLoading(false); } }; - const handleQualitySelect = (torrent: Torrent) => { - setSelectedMagnet(torrent.magnet); - setIsCopied(false); + const filteredTorrents = useMemo(() => { + if (!torrents) return []; + + let filtered = torrents; + + if (type === 'tv' && selectedSeason !== null && availableSeasons.length > 0) { + filtered = filtered.filter(torrent => torrent.season === selectedSeason); + } + + if (selectedQuality !== 'all') { + filtered = filtered.filter(torrent => torrent.quality === selectedQuality); + } + + return filtered.sort((a, b) => { + const qualityOrder = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP']; + const aQualityIndex = qualityOrder.indexOf(a.quality || ''); + const bQualityIndex = qualityOrder.indexOf(b.quality || ''); + + if (aQualityIndex === -1 && bQualityIndex === -1) return 0; + if (aQualityIndex === -1) return 1; + if (bQualityIndex === -1) return -1; + return aQualityIndex - bQualityIndex; + }); + }, [torrents, selectedSeason, selectedQuality, type, availableSeasons]); + + const availableQualities = useMemo(() => { + if (!torrents) return []; + const qualities = [...new Set(torrents.map(t => t.quality).filter(q => q && q !== 'UNKNOWN'))]; + return qualities.sort((a, b) => { + const order = ['4K', '2160P', '1080P', '720P', '480P', 'HDTV', 'WEBRIP', 'BDRIP', 'DVDRIP', 'CAMRIP', 'TS', 'TC']; + const indexA = order.indexOf(a!); + const indexB = order.indexOf(b!); + if (indexA === -1 && indexB === -1) return a!.localeCompare(b!); + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; + }); + }, [torrents]); + + const handleCopy = (magnet: string) => { + navigator.clipboard.writeText(magnet); + setCopiedMagnet(magnet); + setTimeout(() => setCopiedMagnet(null), 2000); }; - const handleCopy = () => { - if (!selectedMagnet) return; - navigator.clipboard.writeText(selectedMagnet); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); + const handleDownload = (magnet: string) => { + window.open(magnet, '_blank'); + }; + + const TorrentCard = ({ torrent }: { torrent: ParsedTorrent }) => { + const getAdditionalInfo = (title: string) => { + const info: string[] = []; + + if (title.match(/rus/i)) info.push('RUS'); + if (title.match(/eng/i)) info.push('ENG'); + if (title.match(/x264/i)) info.push('x264'); + if (title.match(/x265|HEVC/i)) info.push('HEVC'); + if (title.match(/HDR/i)) info.push('HDR'); + if (title.match(/Dolby/i)) info.push('Dolby'); + + return info; + }; + + const additionalInfo = getAdditionalInfo(torrent.title || ''); + + return ( +
+
+
+

+ {torrent.title || 'Раздача'} +

+
+ {torrent.quality && torrent.quality !== 'UNKNOWN' && ( + + {torrent.quality} + + )} + {type === 'tv' && torrent.season && ( + + Сезон {torrent.season} + + )} + {torrent.sizeFormatted && ( + + {torrent.sizeFormatted} + + )} + {additionalInfo.map(info => ( + + {info} + + ))} +
+
+
+ +
+ + + +
+
+ ); }; if (loading) { return (
- - Загрузка торрентов... + + Загрузка торрентов...
); } if (error) { return ( -
- +
+ {error}
); } - if (!torrents) return null; - - const renderTorrentButtons = (list: Torrent[]) => { - if (!list?.length) { - return ( -

- Торрентов для выбранного сезона нет. -

- ); - } - - return list.map(torrent => { - const size = torrent.size_gb; - const label = torrent.title || torrent.name || 'Раздача'; - - return ( - - ); - }); - }; + if (!torrents || torrents.length === 0) { + return null; + } return ( -
- {type === 'tv' && totalSeasons && totalSeasons > 0 && ( -
-

Сезоны

-
- {Array.from({ length: totalSeasons }, (_, i) => i + 1).map(season => ( - - ))} -
-
- )} - - {selectedSeason && torrents && ( -
-

Раздачи

-
- {renderTorrentButtons(torrents)} -
-
- )} - - {selectedMagnet && ( -
-

Magnet-ссылка

-
-
- {selectedMagnet} +
+ + + + + + + + + Выберите раздачу для скачивания + +
+ Найдено {filteredTorrents.length} из {torrents.length} раздач +
+
+ +
+
+ {availableQualities.length > 0 && ( +
+ +
+ + {availableQualities.map(quality => { + const count = torrents.filter(t => + t.quality === quality && + (type !== 'tv' || selectedSeason === null || availableSeasons.length === 0 || t.season === selectedSeason) + ).length; + return ( + + ); + })} +
+
+ )} + + {type === 'tv' && availableSeasons.length > 0 && ( +
+ +
+ + {availableSeasons.map(season => { + const count = torrents?.filter(t => t.season === season && (selectedQuality === 'all' || t.quality === selectedQuality)).length || 0; + return ( + + ); + })} +
+
+ )} +
+ +
+ {filteredTorrents.length === 0 ? ( +
+ Нет раздач, соответствующих выбранным фильтрам +
+ ) : ( + filteredTorrents.map((torrent, index) => ( + + )) + )}
-
-
- )} + +
); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..4327940 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} \ No newline at end of file diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..c546248 --- /dev/null +++ b/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index dccf6fe..93fea9a 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { authAPI } from '../lib/authApi'; -import { api } from '../lib/api'; +import { neoApi } from '../lib/neoApi'; interface PendingRegistration { email: string; @@ -16,34 +16,35 @@ export function useAuth() { const [pending, setPending] = useState(null); const login = async (email: string, password: string) => { - const { data } = await authAPI.login(email, password); - if (data?.token) { - localStorage.setItem('token', data.token); - - // Extract name/email either from API response or JWT payload - let name: string | undefined = undefined; - let email: string | undefined = undefined; - try { - const payload = JSON.parse(atob(data.token.split('.')[1])); - name = payload.name || payload.username || payload.userName || payload.sub || undefined; - email = payload.email || undefined; - } catch { - // silent + try { + const response = await authAPI.login(email, password); + const data = response.data.data || response.data; + if (data?.token) { + localStorage.setItem('token', data.token); + let name: string | undefined = undefined; + let emailVal: string | undefined = undefined; + try { + const payload = JSON.parse(atob(data.token.split('.')[1])); + name = payload.name || payload.username || payload.userName || payload.sub || undefined; + emailVal = payload.email || undefined; + } catch {} + 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; - if (!email) email = data.user?.email || data.email; - - if (name) localStorage.setItem('userName', name); - if (email) localStorage.setItem('userEmail', email); - - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event('auth-changed')); + } catch (err: any) { + if (err?.response?.status === 401 || err?.response?.status === 400) { + throw new Error('Неверный логин или пароль'); } - - api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`; - router.push('/'); - } else { - throw new Error(data?.error || 'Login failed'); + throw new Error(err?.message || 'Произошла ошибка'); } }; @@ -66,14 +67,11 @@ export function useAuth() { setPending(pendingData); } } - if (!pendingData) { throw new Error('Сессия подтверждения истекла. Пожалуйста, попробуйте зарегистрироваться снова.'); } - await authAPI.verify(pendingData.email, code); await login(pendingData.email, pendingData.password); - if (typeof window !== 'undefined') { localStorage.removeItem('pendingVerification'); } @@ -83,7 +81,7 @@ export function useAuth() { const logout = () => { localStorage.removeItem('token'); - delete api.defaults.headers.common['Authorization']; + delete neoApi.defaults.headers.common['Authorization']; localStorage.removeItem('userName'); localStorage.removeItem('userEmail'); if (typeof window !== 'undefined') { diff --git a/src/hooks/useMovies.ts b/src/hooks/useMovies.ts index e242cb2..3d145d9 100644 --- a/src/hooks/useMovies.ts +++ b/src/hooks/useMovies.ts @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { moviesAPI } from '@/lib/neoApi'; import type { Movie, MovieResponse } from '@/lib/neoApi'; -export type MovieCategory = 'popular' | 'top_rated' | 'now_playing'; +export type MovieCategory = 'popular' | 'top-rated' | 'now-playing' | 'upcoming'; interface UseMoviesProps { initialPage?: number; @@ -26,12 +26,15 @@ export function useMovies({ initialPage = 1, category = 'popular' }: UseMoviesPr let response: { data: MovieResponse }; switch (movieCategory) { - case 'top_rated': + case 'top-rated': response = await moviesAPI.getTopRated(pageNum); break; - case 'now_playing': + case 'now-playing': response = await moviesAPI.getNowPlaying(pageNum); break; + case 'upcoming': + response = await moviesAPI.getUpcoming(pageNum); + break; case 'popular': default: response = await moviesAPI.getPopular(pageNum); diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index a8eca0b..2f400a8 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { moviesAPI } from '@/lib/api'; -import type { Movie } from '@/lib/api'; +import { searchAPI } from '@/lib/neoApi'; +import type { Movie } from '@/lib/neoApi'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; @@ -48,7 +48,7 @@ export function useSearch() { setCurrentQuery(query); setCurrentPage(1); - const response = await moviesAPI.searchMovies(query, 1); + const response = await searchAPI.multiSearch(query, 1); const filteredMovies = filterMovies(response.data.results); if (filteredMovies.length === 0) { @@ -74,7 +74,7 @@ export function useSearch() { setLoading(true); const nextPage = currentPage + 1; - const response = await moviesAPI.searchMovies(currentQuery, nextPage); + const response = await searchAPI.multiSearch(currentQuery, nextPage); const filteredMovies = filterMovies(response.data.results); setResults(prev => [...prev, ...filteredMovies]); diff --git a/src/hooks/useTMDBMovies.ts b/src/hooks/useTMDBMovies.ts deleted file mode 100644 index 11af920..0000000 --- a/src/hooks/useTMDBMovies.ts +++ /dev/null @@ -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([]); - const [featuredMovie, setFeaturedMovie] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 - }; -} diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index e9127be..ea2610e 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -17,8 +17,7 @@ export function useUser() { const login = async (email: string, password: string) => { try { - // Сначала проверяем, верифицирован ли аккаунт - const verificationCheck = await fetch('/api/auth/check-verification', { + const verificationCheck = await fetch('/api/v1/auth/check-verification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) @@ -27,8 +26,7 @@ export function useUser() { const { isVerified } = await verificationCheck.json(); if (!isVerified) { - // Если аккаунт не верифицирован, отправляем новый код и переходим к верификации - const verificationResponse = await fetch('/api/auth/verify', { + const verificationResponse = await fetch('/api/v1/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) @@ -43,15 +41,28 @@ export function useUser() { return; } - // Если аккаунт верифицирован, выполняем вход - const result = await signIn('credentials', { - redirect: false, - email, - password, + const loginResponse = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) }); - if (result?.error) { - throw new Error(result.error); + if (!loginResponse.ok) { + const data = await loginResponse.json(); + throw new Error(data.error || 'Неверный email или пароль'); + } + + const loginData = await loginResponse.json(); + const { token, user } = loginData.data || loginData; + + if (token) { + localStorage.setItem('token', token); + if (user?.name) localStorage.setItem('userName', user.name); + if (user?.email) localStorage.setItem('userEmail', user.email); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('auth-changed')); + } } router.push('/'); @@ -62,7 +73,7 @@ export function useUser() { const register = async (email: string, password: string, name: string) => { try { - const response = await fetch('/api/auth/register', { + const response = await fetch('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, name }), @@ -73,8 +84,7 @@ export function useUser() { throw new Error(data.error || 'Ошибка при регистрации'); } - // Отправляем код подтверждения - const verificationResponse = await fetch('/api/auth/verify', { + const verificationResponse = await fetch('/api/v1/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) @@ -98,7 +108,7 @@ export function useUser() { } try { - const response = await fetch('/api/auth/verify', { + const response = await fetch('/api/v1/auth/verify', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -112,15 +122,31 @@ export function useUser() { throw new Error(data.error || 'Неверный код подтверждения'); } - // После успешной верификации выполняем вход - const result = await signIn('credentials', { - redirect: false, - email: pendingRegistration.email, - password: pendingRegistration.password, + const loginResponse = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: pendingRegistration.email, + password: pendingRegistration.password + }) }); - if (result?.error) { - throw new Error(result.error); + if (!loginResponse.ok) { + const data = await loginResponse.json(); + throw new Error(data.error || 'Ошибка входа после верификации'); + } + + const loginData = await loginResponse.json(); + const { token, user } = loginData.data || loginData; + + if (token) { + localStorage.setItem('token', token); + if (user?.name) localStorage.setItem('userName', user.name); + if (user?.email) localStorage.setItem('userEmail', user.email); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('auth-changed')); + } } setIsVerifying(false); @@ -132,7 +158,15 @@ export function useUser() { }; const logout = () => { - signOut({ callbackUrl: '/login' }); + localStorage.removeItem('token'); + localStorage.removeItem('userName'); + localStorage.removeItem('userEmail'); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('auth-changed')); + } + + router.push('/login'); }; return { diff --git a/src/lib/api.ts b/src/lib/api.ts deleted file mode 100644 index 96e6530..0000000 --- a/src/lib/api.ts +++ /dev/null @@ -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(`/categories/${id}`); - }, - - // Получение фильмов по категории - getMoviesByCategory(categoryId: number, page = 1) { - return api.get(`/categories/${categoryId}/movies`, { - params: { page } - }); - }, - - // Получение сериалов по категории - getTVShowsByCategory(categoryId: number, page = 1) { - return api.get(`/categories/${categoryId}/tv`, { - params: { page } - }); - } -}; - -export const moviesAPI = { - // Получение популярных фильмов - getPopular(page = 1) { - return api.get('/movies/popular', { - params: { page } - }); - }, - - // Получение данных о фильме по его TMDB ID - getMovie(id: string | number) { - return api.get(`/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('/movies/search', { - params: { query, page } - }); - }, - - // Получение предстоящих фильмов - getUpcoming(page = 1) { - return api.get('/movies/upcoming', { - params: { page } - }); - }, - - // Получение лучших фильмов - getTopRated(page = 1) { - return api.get('/movies/top-rated', { - params: { page } - }); - }, - - // Получение фильмов по жанру - getMoviesByGenre(genreId: number, page = 1) { - return api.get('/movies/discover', { - params: { with_genres: genreId, page } - }); - }, - - // Получение жанров - getGenres() { - return api.get<{ genres: Genre[] }>('/movies/genres'); - } -}; - -export const tvAPI = { - // Получение популярных сериалов - getPopular(page = 1) { - return api.get('/tv/popular', { - params: { page } - }); - }, - - // Получение данных о сериале по его TMDB ID - getShow(id: string | number) { - return api.get(`/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('/tv/search', { - params: { query, page } - }); - } -}; - -// Мультипоиск (фильмы и сериалы) -export const searchAPI = { - multiSearch(query: string, page = 1) { - return api.get('/search/multi', { - params: { query, page } - }); - } -}; diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts index 8602c24..1522598 100644 --- a/src/lib/authApi.ts +++ b/src/lib/authApi.ts @@ -1,19 +1,10 @@ -import { api } from './api'; +import { neoApi } from './neoApi'; export const authAPI = { - register(data: { email: string; password: string; name?: string }) { - return api.post('/auth/register', data); - }, - resendCode(email: string) { - return api.post('/auth/resend-code', { email }); - }, - verify(email: string, code: string) { - return api.post('/auth/verify', { email, code }); - }, - login(email: string, password: string) { - return api.post('/auth/login', { email, password }); - }, - deleteAccount() { - return api.delete('/auth/profile'); - } + register: (data: any) => neoApi.post('/api/v1/auth/register', data), + resendCode: (email: string) => neoApi.post('/api/v1/auth/resend-code', { email }), + verify: (email: string, code: string) => neoApi.post('/api/v1/auth/verify', { email, code }), + checkVerification: (email: string) => neoApi.post('/api/v1/auth/check-verification', { email }), + login: (email: string, password: string) => neoApi.post('/api/v1/auth/login', { email, password }), + deleteAccount: () => neoApi.delete('/api/v1/auth/profile'), }; diff --git a/src/lib/favoritesApi.ts b/src/lib/favoritesApi.ts index 791ed5d..9ca25a9 100644 --- a/src/lib/favoritesApi.ts +++ b/src/lib/favoritesApi.ts @@ -1,25 +1,24 @@ -import { api } from './api'; - +import { neoApi } from './neoApi'; export const favoritesAPI = { - // Получить все избранные + // Получение всех избранных getFavorites() { - return api.get('/favorites'); + return neoApi.get('/api/v1/favorites'); }, - // Добавить в избранное - addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) { - const { mediaId, mediaType, title, posterPath } = data; - return api.post(`/favorites/${mediaId}?mediaType=${mediaType}`, { title, posterPath }); + // Добавление в избранное + addFavorite(data: { mediaId: string; mediaType: string; title: string; posterPath?: string }) { + const { mediaId, mediaType, ...rest } = data; + return neoApi.post(`/api/v1/favorites/${mediaId}?mediaType=${mediaType}`, rest); }, - // Удалить из избранного + // Удаление из избранного removeFavorite(mediaId: string) { - return api.delete(`/favorites/${mediaId}`); + return neoApi.delete(`/api/v1/favorites/${mediaId}`); }, - // Проверить есть ли в избранном + // Проверка, добавлен ли в избранное checkFavorite(mediaId: string) { - return api.get(`/favorites/check/${mediaId}`); + return neoApi.get(`/api/v1/favorites/check/${mediaId}`); } }; diff --git a/src/lib/mongodb.ts b/src/lib/mongodb.ts index 6949022..90e5a04 100644 --- a/src/lib/mongodb.ts +++ b/src/lib/mongodb.ts @@ -29,32 +29,26 @@ export async function connectToDatabase() { return { db, client }; } -// Инициализация MongoDB export async function initMongoDB() { try { const { db } = await connectToDatabase(); - // Создаем уникальный индекс для избранного await db.collection('favorites').createIndex( { userId: 1, mediaId: 1, mediaType: 1 }, { unique: true } ); - console.log('MongoDB initialized successfully'); } catch (error) { console.error('Error initializing MongoDB:', error); throw error; } } -// Функция для сброса и создания индексов export async function resetIndexes() { const { db } = await connectToDatabase(); - // Удаляем все индексы из коллекции favorites await db.collection('favorites').dropIndexes(); - // Создаем новый правильный индекс await db.collection('favorites').createIndex( { userId: 1, mediaId: 1, mediaType: 1 }, { unique: true } diff --git a/src/lib/neoApi.ts b/src/lib/neoApi.ts index f4ba8b3..d4fa90f 100644 --- a/src/lib/neoApi.ts +++ b/src/lib/neoApi.ts @@ -1,18 +1,25 @@ import axios from 'axios'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://neomovies-test-api.vercel.app'; +// Создание экземпляра Axios с базовыми настройками export const neoApi = axios.create({ baseURL: API_URL, headers: { 'Content-Type': 'application/json' }, - timeout: 30000 // Увеличиваем таймаут до 30 секунд + timeout: 30000 }); -// Добавляем перехватчики запросов +// Перехватчик запросов neoApi.interceptors.request.use( (config) => { + // Получение токена из localStorage или другого хранилища + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + // Логика для пагинации if (config.params?.page) { const page = parseInt(config.params.page); if (isNaN(page) || page < 1) { @@ -27,29 +34,34 @@ neoApi.interceptors.request.use( } ); -// Добавляем перехватчики ответов +// Перехватчик ответов neoApi.interceptors.response.use( (response) => { + if (response.data && response.data.success && response.data.data !== undefined) { + response.data = response.data.data; + } return response; }, (error) => { console.error('❌ Response Error:', { status: error.response?.status, + statusText: error.response?.statusText, url: error.config?.url, method: error.config?.method, - message: error.message + message: error.message, + data: error.response?.data }); return Promise.reject(error); } ); -// Функция для получения URL изображения export const getImageUrl = (path: string | null, size: string = 'w500'): string => { if (!path) return '/images/placeholder.jpg'; - // Извлекаем только ID изображения из полного пути - const imageId = path.split('/').pop(); - if (!imageId) return '/images/placeholder.jpg'; - return `${API_URL}/images/${size}/${imageId}`; + if (path.startsWith('http')) { + return path; + } + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + return `${API_URL}/api/v1/images/${size}/${cleanPath}`; }; export interface Genre { @@ -80,10 +92,44 @@ export interface MovieResponse { total_results: number; } +export interface TorrentResult { + title: string; + tracker: string; + size: string; + seeders: number; + peers: number; + leechers: number; + quality: string; + voice?: string[]; + types?: string[]; + seasons?: number[]; + category: string; + magnet: string; + torrent_link?: string; + details?: string; + publish_date: string; + added_date?: string; + source: string; +} + +export interface TorrentSearchResponse { + query: string; + results: TorrentResult[]; + total: number; +} + +export interface AvailableSeasonsResponse { + title: string; + originalTitle: string; + year: string; + seasons: number[]; + total: number; +} + export const searchAPI = { // Поиск фильмов searchMovies(query: string, page = 1) { - return neoApi.get('/movies/search', { + return neoApi.get('/api/v1/movies/search', { params: { query, page @@ -94,7 +140,7 @@ export const searchAPI = { // Поиск сериалов searchTV(query: string, page = 1) { - return neoApi.get('/tv/search', { + return neoApi.get('/api/v1/tv/search', { params: { query, page @@ -103,46 +149,19 @@ export const searchAPI = { }); }, - // Мультипоиск (фильмы и сериалы) + // Мультипоиск (фильмы и сериалы) - новый эндпоинт async multiSearch(query: string, page = 1) { - // Запускаем параллельные запросы к фильмам и сериалам try { - const [moviesResponse, tvResponse] = await Promise.all([ - this.searchMovies(query, page), - this.searchTV(query, page) - ]); + // Используем новый эндпоинт Go API + const response = await neoApi.get('/search/multi', { + params: { + query, + page + }, + timeout: 30000 + }); - // Объединяем результаты - const moviesData = moviesResponse.data; - const tvData = tvResponse.data; - - // Метаданные для пагинации - const totalResults = (moviesData.total_results || 0) + (tvData.total_results || 0); - const totalPages = Math.max(moviesData.total_pages || 0, tvData.total_pages || 0); - - // Добавляем информацию о типе контента - const moviesWithType = (moviesData.results || []).map(movie => ({ - ...movie, - media_type: 'movie' - })); - - const tvWithType = (tvData.results || []).map(show => ({ - ...show, - media_type: 'tv' - })); - - // Объединяем и сортируем по популярности - const combinedResults = [...moviesWithType, ...tvWithType] - .sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); - - return { - data: { - page: parseInt(String(page)), - results: combinedResults, - total_pages: totalPages, - total_results: totalResults - } - }; + return response; } catch (error) { console.error('Error in multiSearch:', error); throw error; @@ -153,7 +172,7 @@ export const searchAPI = { export const moviesAPI = { // Получение популярных фильмов getPopular(page = 1) { - return neoApi.get('/movies/popular', { + return neoApi.get('/api/v1/movies/popular', { params: { page }, timeout: 30000 }); @@ -161,7 +180,7 @@ export const moviesAPI = { // Получение фильмов с высоким рейтингом getTopRated(page = 1) { - return neoApi.get('/movies/top_rated', { + return neoApi.get('/api/v1/movies/top-rated', { params: { page }, timeout: 30000 }); @@ -169,7 +188,15 @@ export const moviesAPI = { // Получение новинок getNowPlaying(page = 1) { - return neoApi.get('/movies/now_playing', { + return neoApi.get('/api/v1/movies/now-playing', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение предстоящих фильмов + getUpcoming(page = 1) { + return neoApi.get('/api/v1/movies/upcoming', { params: { page }, timeout: 30000 }); @@ -177,12 +204,12 @@ export const moviesAPI = { // Получение данных о фильме по его ID getMovie(id: string | number) { - return neoApi.get(`/movies/${id}`, { timeout: 30000 }); + return neoApi.get(`/api/v1/movies/${id}`, { timeout: 30000 }); }, // Поиск фильмов searchMovies(query: string, page = 1) { - return neoApi.get('/movies/search', { + return neoApi.get('/api/v1/movies/search', { params: { query, page @@ -191,16 +218,40 @@ export const moviesAPI = { }); }, - // Получение IMDB ID - getImdbId(id: string | number) { - return neoApi.get(`/movies/${id}/external_ids`, { timeout: 30000 }).then(res => res.data.imdb_id); + // Получение IMDB и других external ids + getExternalIds(id: string | number) { + return neoApi.get(`/api/v1/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data); } }; export const tvShowsAPI = { // Получение популярных сериалов getPopular(page = 1) { - return neoApi.get('/tv/popular', { + return neoApi.get('/api/v1/tv/popular', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение сериалов с высоким рейтингом + getTopRated(page = 1) { + return neoApi.get('/api/v1/tv/top-rated', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение сериалов в эфире + getOnTheAir(page = 1) { + return neoApi.get('/api/v1/tv/on-the-air', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение сериалов, которые выходят сегодня + getAiringToday(page = 1) { + return neoApi.get('/api/v1/tv/airing-today', { params: { page }, timeout: 30000 }); @@ -208,12 +259,12 @@ export const tvShowsAPI = { // Получение данных о сериале по его ID getTVShow(id: string | number) { - return neoApi.get(`/tv/${id}`, { timeout: 30000 }); + return neoApi.get(`/api/v1/tv/${id}`, { timeout: 30000 }); }, // Поиск сериалов searchTVShows(query: string, page = 1) { - return neoApi.get('/tv/search', { + return neoApi.get('/api/v1/tv/search', { params: { query, page @@ -222,8 +273,101 @@ export const tvShowsAPI = { }); }, - // Получение IMDB ID - getImdbId(id: string | number) { - return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id); + // Получение IMDB и других external ids + getExternalIds(id: string | number) { + return neoApi.get(`/api/v1/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data); + } +}; + +export const torrentsAPI = { + // Поиск торрентов по IMDB ID + searchTorrents(imdbId: string, type: 'movie' | 'tv', options?: { + season?: number; + quality?: string; + minQuality?: string; + maxQuality?: string; + excludeQualities?: string; + hdr?: boolean; + hevc?: boolean; + sortBy?: string; + sortOrder?: string; + groupByQuality?: boolean; + groupBySeason?: boolean; + }) { + const params: any = { type }; + + if (options) { + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === 'excludeQualities' && Array.isArray(value)) { + params[key] = value.join(','); + } else { + params[key] = value; + } + } + }); + } + + return neoApi.get(`/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('/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('/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(`/api/v1/categories/${id}`); + }, + + // Получение фильмов по категории + getMoviesByCategory(categoryId: number, page = 1) { + return neoApi.get(`/api/v1/categories/${categoryId}/movies`, { + params: { page } + }); + }, + + // Получение сериалов по категории + getTVShowsByCategory(categoryId: number, page = 1) { + return neoApi.get(`/api/v1/categories/${categoryId}/tv`, { + params: { page } + }); + } +}; + +// Новый API-клиент для работы с аутентификацией и профилем +export const authAPI = { + // Новый метод для удаления аккаунта + deleteAccount() { + return neoApi.delete('/api/v1/profile'); } }; diff --git a/src/lib/reactionsApi.ts b/src/lib/reactionsApi.ts index 9a1fac0..ad99200 100644 --- a/src/lib/reactionsApi.ts +++ b/src/lib/reactionsApi.ts @@ -1,28 +1,30 @@ -import { api } from './api'; +import { neoApi } from './neoApi'; export interface Reaction { - _id: string; - userId: string; + type: 'like' | 'dislike'; mediaId: string; mediaType: 'movie' | 'tv'; - type: 'fire' | 'nice' | 'think' | 'bore' | 'shit'; - createdAt: string; } export const reactionsAPI = { - // [PUBLIC] Получить счетчики для всех типов реакций - getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record }> { - return api.get(`/reactions/${mediaType}/${mediaId}/counts`); + // Получение счетчиков реакций + getReactionCounts(mediaType: string, mediaId: string) { + return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/counts`); }, - // [AUTH] Получить реакцию пользователя для медиа - getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> { - return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`); + // Получение моей реакции + getMyReaction(mediaType: string, mediaId: string) { + return neoApi.get(`/api/v1/reactions/${mediaType}/${mediaId}/my-reaction`); }, - // [AUTH] Установить/обновить/удалить реакцию - setReaction(mediaType: string, mediaId: string, type: Reaction['type']): Promise<{ data: Reaction }> { + // Установка реакции + setReaction(mediaType: string, mediaId: string, type: 'like' | 'dislike') { const fullMediaId = `${mediaType}_${mediaId}`; - return api.post('/reactions', { mediaId: fullMediaId, type }); + return neoApi.post('/api/v1/reactions', { mediaId: fullMediaId, type }); }, + + // Удаление реакции + removeReaction(mediaType: string, mediaId: string) { + return neoApi.delete(`/api/v1/reactions/${mediaType}/${mediaId}`); + } }; \ No newline at end of file diff --git a/src/models/Favorite.ts b/src/models/Favorite.ts index 591f2d2..702bfec 100644 --- a/src/models/Favorite.ts +++ b/src/models/Favorite.ts @@ -35,7 +35,6 @@ const FavoriteSchema: Schema = new Schema({ timestamps: true, }); -// Ensure a user can't favorite the same item multiple times FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true }); export default mongoose.models.Favorite || mongoose.model('Favorite', FavoriteSchema); diff --git a/src/models/User.ts b/src/models/User.ts index aa109ee..9176d16 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -40,7 +40,6 @@ const userSchema = new Schema({ timestamps: true, }); -// Не включаем пароль в запросы по умолчанию userSchema.set('toJSON', { transform: function(doc, ret) { delete ret.password; @@ -48,7 +47,6 @@ userSchema.set('toJSON', { } }); -// Хэшируем пароль перед сохранением userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next(); @@ -61,7 +59,6 @@ userSchema.pre('save', async function(next) { } }); -// Метод для проверки пароля userSchema.methods.comparePassword = async function(candidatePassword: string) { try { return await bcrypt.compare(candidatePassword, this.password);