diff --git a/README.md b/README.md index 279af52..a164221 100644 --- a/README.md +++ b/README.md @@ -39,94 +39,49 @@ Neo Movies - это современная веб-платформа для пр - Git - npm -## Установка +## Начало работы -1. **Клонируйте репозиторий:** - ```bash - git clone https://gitlab.com/foxixus/neomovies.git - cd neomovies - ``` - -2. **Установите зависимости:** - ```bash - npm install - ``` - -3. **Создайте файл `.env` в корневой директории и добавьте следующие переменные:** - ```env - # База данных MongoDB - MONGODB_URI=your_mongodb_uri - - # NextAuth конфигурация - NEXTAUTH_SECRET=your_nextauth_secret - NEXTAUTH_URL=http://localhost:3000 - - # Google OAuth - GOOGLE_CLIENT_ID=your_google_client_id - GOOGLE_CLIENT_SECRET=your_google_client_secret - - # Email конфигурация (для подтверждения регистрации) - GMAIL_USER=your_gmail@gmail.com - GMAIL_APP_PASSWORD=your_app_specific_password - - # TMDB API (для получения информации о фильмах) - NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key - NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token - - # JWT конфигурация - JWT_SECRET=your_jwt_secret - - # Lumex Player URL - NEXT_PUBLIC_LUMEX_URL=your_lumex_player_url - ``` - -4. **Запустите проект:** - ```bash - # Режим разработки - npm run dev - - # Сборка для продакшена - npm run build - npm start - ``` - -## Получение API ключей - -### TMDB API -1. Создайте аккаунт на [TMDB](https://www.themoviedb.org/) -2. Перейдите в настройки профиля -> API -3. Создайте новое API приложение -4. Скопируйте API ключ и Access Token - -### Google OAuth -1. Перейдите в [Google Cloud Console](https://console.cloud.google.com/) -2. Создайте новый проект -3. Включите Google OAuth API -4. Создайте учетные данные OAuth 2.0 -5. Добавьте разрешенные URI перенаправления: - - http://localhost:3000/api/auth/callback/google - - https://your-domain.com/api/auth/callback/google - -### Gmail App Password -1. Включите двухфакторную аутентификацию в аккаунте Google -2. Перейдите в настройки безопасности -3. Создайте пароль приложения -4. Используйте этот пароль в GMAIL_APP_PASSWORD - -## Разработка - -### Структура проекта +1. Клонируйте репозиторий: +```bash +git clone https://gitlab.com/foxixus/neomovies.git +cd neomovies ``` -neo-movies-web/ -├── src/ -│ ├── app/ # App Router pages -│ ├── components/ # React компоненты -│ ├── hooks/ # React хуки -│ ├── lib/ # Утилиты и API -│ ├── models/ # MongoDB модели -│ └── styles/ # Глобальные стили -├── public/ # Статические файлы -└── package.json + +2. Установите зависимости: +```bash +npm install +``` + +3. Создайте файл `.env` и добавьте следующие переменные: +```env +NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app +``` + +4. Запустите приложение: +```bash +npm run dev +``` + +Приложение будет доступно по адресу [http://localhost:3000](http://localhost:3000) + +## API + +Приложение использует отдельный API сервер. API предоставляет следующие возможности: + +- Поиск фильмов и сериалов +- Получение детальной информации о фильме/сериале +- Оптимизированная загрузка изображений +- Кэширование запросов + +## Структура проекта + +``` +src/ + ├── app/ # App Router и страницы + ├── components/ # React компоненты + ├── lib/ # Утилиты и API клиенты + ├── types/ # TypeScript типы + └── utils/ # Вспомогательные функции ``` ## 👥 Авторы @@ -135,7 +90,7 @@ neo-movies-web/ ## 📄 Лицензия -Этот проект распространяется под лицензией MIT. Подробности в файле [LICENSE](LICENSE). +Этот проект распространяется под лицензией Apache-2.0. Подробности в файле [LICENSE](LICENSE). ## 🤝 Участие в проекте @@ -146,6 +101,12 @@ neo-movies-web/ 3. Внесите изменения 4. Отправьте pull request + +## Благодарности + +- [TMDB](https://www.themoviedb.org/) за предоставление API +- [Vercel](https://vercel.com/) за хостинг API + ## 📞 Контакты Если у вас возникли вопросы или предложения, свяжитесь с нами: diff --git a/next.config.js b/next.config.js index 0720289..0cc2d9c 100644 --- a/next.config.js +++ b/next.config.js @@ -21,6 +21,12 @@ const nextConfig = { hostname: 'image.tmdb.org', pathname: '/**', }, + { + protocol: 'http', + hostname: 'localhost', + port: '3010', + pathname: '/images/**', + } ], }, onDemandEntries: { diff --git a/package-lock.json b/package-lock.json index ebb1524..9744e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "framer-motion": "^11.15.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "lucide-react": "^0.469.0", "mongodb": "^6.12.0", "mongoose": "^8.9.2", "next": "15.1.2", @@ -4824,6 +4825,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index dd262ef..dd7c8ca 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "framer-motion": "^11.15.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "lucide-react": "^0.469.0", "mongodb": "^6.12.0", "mongoose": "^8.9.2", "next": "15.1.2", diff --git a/src/api.ts b/src/api.ts index e7dc46e..997c3b2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -const BASE_URL = 'https://api.themoviedb.org/3'; +const BASE_URL = '/api/movies'; if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) { throw new Error('TMDB_ACCESS_TOKEN is not defined in environment variables'); @@ -9,7 +9,6 @@ if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) export const api = axios.create({ baseURL: BASE_URL, headers: { - 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`, 'Content-Type': 'application/json' } }); @@ -20,7 +19,21 @@ export interface MovieDetails extends Movie { tagline: string; budget: number; revenue: number; - videos: { + videos: {{ + "page": 1, + "results": [ + { + "id": 123, + "title": "Movie Title", + "overview": "Movie description", + "poster_path": "/path/to/poster.jpg", + ... + }, + ... + ], + "total_pages": 500, + "total_results": 10000 + } results: Video[]; }; credits: { @@ -53,101 +66,32 @@ export interface Crew { export const moviesAPI = { // Получение популярных фильмов - getPopular: (page = 1) => - 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], // только вышедшие фильмы - } - }), - - // Получение данных о фильме по его TMDB ID - getMovie: (id: string | number) => - api.get(`/movie/${id}`, { - params: { - language: 'ru-RU', - append_to_response: 'credits,videos,similar' // дополнительная информация - } - }), - - // Поиск фильмов - searchMovies: (query: string, page = 1) => - api.get('/search/movie', { - params: { - query, - page, - language: 'ru-RU', - include_adult: false, - 'primary_release_date.lte': new Date().toISOString().split('T')[0] - } - }), - - // Получение предстоящих фильмов - getUpcoming: (page = 1) => - api.get('/movie/upcoming', { - params: { - page, - language: 'ru-RU', - } - }), - - // Получение лучших фильмов - getTopRated: (page = 1) => - api.get('/movie/top_rated', { - params: { - page, - language: 'ru-RU', - 'vote_count.gte': 100 - } - }), - - // Получение фильмов по жанру - getMoviesByGenre: (genreId: number, page = 1) => - api.get('/discover/movie', { - params: { - with_genres: genreId, - 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] - } - }), - - // Получение IMDb ID по TMDB ID для плеера - getImdbId: async (tmdbId: string | number) => { - try { - const response = await api.get(`/movie/${tmdbId}`, { - params: { - language: 'en-US', // Язык для IMDb ID - }, - }); - return response.data.imdb_id; - } catch (error) { - console.error('Ошибка при получении IMDb ID:', error); - return null; - } + async getPopular(page = 1) { + const response = await api.get(`/popular?page=${page}`); + return response.data; }, - // Получение видео по TMDB ID для плеера - getVideo: async (tmdbId: string | number) => { - try { - const response = await api.get(`/movie/${tmdbId}/videos`, { - params: { - language: 'en-US', // Язык для видео - }, - }); - return response.data.results; - } catch (error) { - console.error('Ошибка при получении видео:', error); - return []; - } + // Получение данных о фильме + async getMovie(id: string | number) { + const response = await api.get(`/${id}`); + return response.data; + }, + + // Поиск фильмов + async searchMovies(query: string, page = 1) { + const response = await api.get(`/search?query=${encodeURIComponent(query)}&page=${page}`); + return response.data; + }, + + // Получение предстоящих фильмов + async getUpcoming(page = 1) { + const response = await api.get(`/upcoming?page=${page}`); + return response.data; + }, + + // Получение топ рейтинговых фильмов + async getTopRated(page = 1) { + const response = await api.get(`/top-rated?page=${page}`); + return response.data; } }; \ No newline at end of file diff --git a/src/app/api/favorites/[mediaId]/route.ts b/src/app/api/favorites/[mediaId]/route.ts new file mode 100644 index 0000000..8de955c --- /dev/null +++ b/src/app/api/favorites/[mediaId]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { connectToDatabase } from '@/lib/mongodb'; + +// DELETE /api/favorites/[mediaId] - удалить из избранного +export async function DELETE( + request: Request, + { params }: { params: { mediaId: string } } +) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const mediaType = searchParams.get('mediaType'); + const mediaId = params.mediaId; + + if (!mediaType || !mediaId) { + return NextResponse.json({ error: 'Missing mediaType or mediaId' }, { status: 400 }); + } + + const { db } = await connectToDatabase(); + + const result = await db.collection('favorites').deleteOne({ + userId: session.user.email, + mediaId, + mediaType + }); + + if (result.deletedCount === 0) { + return NextResponse.json({ error: 'Favorite not found' }, { status: 404 }); + } + + return NextResponse.json({ message: 'Removed from favorites' }); + } catch (error) { + console.error('Error removing from favorites:', error); + return NextResponse.json({ error: 'Failed to remove from favorites' }, { status: 500 }); + } +} diff --git a/src/app/api/favorites/check/[mediaId]/route.ts b/src/app/api/favorites/check/[mediaId]/route.ts new file mode 100644 index 0000000..1db2ffb --- /dev/null +++ b/src/app/api/favorites/check/[mediaId]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { connectToDatabase } from '@/lib/mongodb'; + +// GET /api/favorites/check/[mediaId] - проверить есть ли в избранном +export async function GET( + request: Request, + { params }: { params: { mediaId: string } } +) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const mediaType = searchParams.get('mediaType'); + + const { db } = await connectToDatabase(); + + const favorite = await db.collection('favorites').findOne({ + userId: session.user.email, + mediaId: params.mediaId, + mediaType + }); + + return NextResponse.json({ isFavorite: !!favorite }); + } catch (error) { + console.error('Error checking favorite:', error); + return NextResponse.json({ error: 'Failed to check favorite status' }, { status: 500 }); + } +} diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts new file mode 100644 index 0000000..a78318b --- /dev/null +++ b/src/app/api/favorites/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { connectToDatabase, resetIndexes } from '@/lib/mongodb'; + +// Флаг для отслеживания инициализации +let isInitialized = false; + +// GET /api/favorites - получить все избранные +export async function GET() { + try { + // Инициализируем индексы при первом запросе + if (!isInitialized) { + await resetIndexes(); + isInitialized = true; + } + + const session = await getServerSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { db } = await connectToDatabase(); + const favorites = await db.collection('favorites') + .find({ userId: session.user.email }) + .sort({ createdAt: -1 }) + .toArray(); + + return NextResponse.json(favorites); + } catch (error) { + console.error('Error getting favorites:', error); + return NextResponse.json({ error: 'Failed to get favorites' }, { status: 500 }); + } +} + +// POST /api/favorites - добавить в избранное +export async function POST(request: Request) { + try { + // Инициализируем индексы при первом запросе + if (!isInitialized) { + await resetIndexes(); + isInitialized = true; + } + + const session = await getServerSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { mediaId, mediaType, title, posterPath } = await request.json(); + + if (!mediaId || !mediaType || !title) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const { db } = await connectToDatabase(); + + const favorite = { + userId: session.user.email, + mediaId: mediaId.toString(), // Преобразуем в строку для консистентности + mediaType, + title, + posterPath, + createdAt: new Date() + }; + + // Используем updateOne с upsert вместо insertOne + const result = await db.collection('favorites').updateOne( + { + userId: session.user.email, + mediaId: favorite.mediaId, + mediaType + }, + { $set: favorite }, + { upsert: true } + ); + + // Если документ был обновлен (уже существовал) + if (result.matchedCount > 0) { + return NextResponse.json({ message: 'Already in favorites' }, { status: 200 }); + } + + // Если документ был создан (новый) + return NextResponse.json(favorite); + } catch (error) { + console.error('Error adding to favorites:', error); + return NextResponse.json({ error: 'Failed to add to favorites' }, { status: 500 }); + } +} diff --git a/src/app/api/movies/[id]/route.ts b/src/app/api/movies/[id]/route.ts new file mode 100644 index 0000000..358e538 --- /dev/null +++ b/src/app/api/movies/[id]/route.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..b311bac --- /dev/null +++ b/src/app/api/movies/popular/route.ts @@ -0,0 +1,37 @@ +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 index 437b8fa..9813201 100644 --- a/src/app/api/movies/search/route.ts +++ b/src/app/api/movies/search/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from 'next/server'; -import { searchAPI } from '@/lib/api'; - -const TMDB_API_KEY = process.env.TMDB_API_KEY; -const TMDB_API_URL = 'https://api.themoviedb.org/3'; +import { searchAPI } from '@/lib/neoApi'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -16,12 +13,15 @@ export async function GET(request: Request) { } try { - const { data } = await searchAPI.multiSearch(query); - return NextResponse.json(data); - } catch (error) { + const response = await searchAPI.multiSearch(query); + return NextResponse.json(response.data); + } catch (error: any) { console.error('Error searching:', error); return NextResponse.json( - { error: 'Failed to search' }, + { + error: 'Failed to search', + details: error.message + }, { status: 500 } ); } diff --git a/src/app/api/movies/top-rated/route.ts b/src/app/api/movies/top-rated/route.ts new file mode 100644 index 0000000..01c1bf6 --- /dev/null +++ b/src/app/api/movies/top-rated/route.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..c9315df --- /dev/null +++ b/src/app/api/movies/upcoming/route.ts @@ -0,0 +1,19 @@ +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/favorites/page.tsx b/src/app/favorites/page.tsx new file mode 100644 index 0000000..0564788 --- /dev/null +++ b/src/app/favorites/page.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useSession } from 'next-auth/react'; +import { favoritesAPI } from '@/lib/favoritesApi'; +import { getImageUrl } from '@/lib/neoApi'; + +const Container = styled.div` + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +`; + +const Title = styled.h1` + font-size: 2rem; + color: white; + margin-bottom: 2rem; +`; + +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 2rem; +`; + +const Card = styled(Link)` + position: relative; + border-radius: 0.5rem; + overflow: hidden; + transition: transform 0.2s; + text-decoration: none; + + &:hover { + transform: translateY(-5px); + } +`; + +const Poster = styled.div` + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; +`; + +const Info = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1rem; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); +`; + +const MediaTitle = styled.h2` + font-size: 1rem; + color: white; + margin: 0; +`; + +const MediaType = styled.span` + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); +`; + +const EmptyState = styled.div` + text-align: center; + color: rgba(255, 255, 255, 0.7); + padding: 4rem 0; +`; + +interface Favorite { + id: number; + mediaId: string; + mediaType: 'movie' | 'tv'; + title: string; + posterPath: string; +} + +export default function FavoritesPage() { + const { data: session } = useSession(); + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchFavorites = async () => { + if (!session?.user) { + setLoading(false); + return; + } + + try { + const response = await favoritesAPI.getFavorites(); + setFavorites(response.data); + } catch (error) { + console.error('Error fetching favorites:', error); + } finally { + setLoading(false); + } + }; + + fetchFavorites(); + }, [session?.user]); + + if (loading) { + return ( + + Избранное + Загрузка... + + ); + } + + if (!session?.user) { + return ( + + Избранное + + Для доступа к избранному необходимо авторизоваться + + + ); + } + + if (favorites.length === 0) { + return ( + + Избранное + + У вас пока нет избранных фильмов и сериалов + + + ); + } + + return ( + + Избранное + + {favorites.map(favorite => ( + + + {favorite.title} + + + {favorite.title} + {favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'} + + + ))} + + + ); +} diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx index bc2e0db..6305341 100644 --- a/src/app/movie/[id]/MovieContent.tsx +++ b/src/app/movie/[id]/MovieContent.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useEffect, useState, Suspense } from 'react'; +import { useState, useEffect } from 'react'; import styled from 'styled-components'; import { moviesAPI } from '@/lib/api'; +import { getImageUrl } from '@/lib/neoApi'; import type { MovieDetails } from '@/lib/api'; import { useSettings } from '@/hooks/useSettings'; import MoviePlayer from '@/components/MoviePlayer'; +import FavoriteButton from '@/components/FavoriteButton'; declare global { interface Window { @@ -120,56 +122,39 @@ const ErrorContainer = styled.div` color: #ff4444; `; - import { useParams } from 'next/navigation'; -export default function MovieContent() { - const { id: movieId } = useParams(); +interface MovieContentProps { + movieId: string; + initialMovie: MovieDetails; +} + +export default function MovieContent({ movieId, initialMovie }: MovieContentProps) { const { settings } = useSettings(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [movie, setMovie] = useState(null); + const [movie] = useState(initialMovie); const [imdbId, setImdbId] = useState(null); useEffect(() => { - const fetchMovie = async () => { - if (!movieId) return; - + const fetchImdbId = async () => { try { - setLoading(true); - const response = await moviesAPI.getMovie(movieId); - setMovie(response.data); - const newImdbId = await moviesAPI.getImdbId(movieId); - if (!newImdbId) { - setError('IMDb ID не найден'); - return; + if (newImdbId) { + setImdbId(newImdbId); } - setImdbId(newImdbId); - - setError(null); } catch (err) { - console.error('Error fetching movie:', err); - setError('Ошибка при загрузке фильма'); - } finally { - setLoading(false); + console.error('Error fetching IMDb ID:', err); } }; - - fetchMovie(); + fetchImdbId(); }, [movieId]); - if (loading) return Загрузка...; - if (error) return {error}; - if (!movie || !imdbId) return null; - return ( @@ -188,19 +173,24 @@ export default function MovieContent() { {movie.tagline && {movie.tagline}} {movie.overview} +
+ +
- - Загрузка плеера...}> - + - - + + )}
); diff --git a/src/app/movie/[id]/page.tsx b/src/app/movie/[id]/page.tsx index ee864ae..c27a0e0 100644 --- a/src/app/movie/[id]/page.tsx +++ b/src/app/movie/[id]/page.tsx @@ -1,5 +1,6 @@ -import MoviePage from './MoviePage'; +import { Metadata } from 'next'; import { moviesAPI } from '@/lib/api'; +import MoviePage from '@/app/movie/[id]/MoviePage'; interface PageProps { params: { @@ -7,17 +8,34 @@ interface PageProps { }; } +// Генерация метаданных для страницы +export async function generateMetadata({ params }: PageProps): Promise { + const { id } = params; + try { + const { data: movie } = await moviesAPI.getMovie(id); + return { + title: `${movie.title} - NeoMovies`, + description: movie.overview, + }; + } catch (error) { + return { + title: 'Фильм - NeoMovies', + }; + } +} + +// Получение данных для страницы async function getData(id: string) { try { - const response = await moviesAPI.getMovie(id); - return { id, movie: response.data }; + const { data: movie } = await moviesAPI.getMovie(id); + return { id, movie }; } catch (error) { - console.error('Error fetching movie:', error); - return { id, movie: null }; + throw new Error('Failed to fetch movie'); } } export default async function Page({ params }: PageProps) { - const data = await getData(params.id); + const { id } = params; + const data = await getData(id); return ; } \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 4667c9f..6cb8236 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,8 @@ import { HeartIcon } from '@/components/Icons/HeartIcon'; import MovieCard from '@/components/MovieCard'; import { useMovies } from '@/hooks/useMovies'; import Pagination from '@/components/Pagination'; +import { getImageUrl } from '@/lib/neoApi'; +import FavoriteButton from '@/components/FavoriteButton'; const Container = styled.div` min-height: 100vh; @@ -19,15 +21,26 @@ const Container = styled.div` } `; -const FeaturedMovie = styled.div` +const FeaturedMovie = styled.div<{ $backdrop: string }>` position: relative; width: 100%; height: 600px; + background-image: ${props => `url(${props.$backdrop})`}; background-size: cover; background-position: center; margin-bottom: 2rem; border-radius: 24px; overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.8) 100%); + } `; const Overlay = styled.div` @@ -36,14 +49,16 @@ const Overlay = styled.div` left: 0; right: 0; bottom: 0; - background: linear-gradient(to right, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0.2) 100%); display: flex; align-items: center; padding: 2rem; `; const FeaturedContent = styled.div` - max-width: 600px; + position: relative; + z-index: 1; + max-width: 800px; + padding: 2rem; color: white; `; @@ -64,49 +79,32 @@ const Title = styled.h1` font-size: 3rem; font-weight: bold; margin-bottom: 1rem; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); `; -const Description = styled.p` +const Overview = styled.p` font-size: 1.125rem; margin-bottom: 2rem; opacity: 0.9; line-height: 1.6; `; -const ButtonGroup = styled.div` +const ButtonContainer = styled.div` display: flex; gap: 1rem; `; -const WatchButton = styled.div` - background: ${props => props.theme.colors.primary}; - color: white; +const WatchButton = styled.button` padding: 0.75rem 2rem; + background: #e50914; + color: white; border: none; - border-radius: 9999px; + border-radius: 0.5rem; font-weight: 500; cursor: pointer; transition: background 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; &:hover { - background: #2563eb; - } - - svg { - width: 20px; - height: 20px; - } -`; - -const FavoriteButton = styled(WatchButton)` - background: rgba(255, 255, 255, 0.1); - - &:hover { - background: rgba(255, 255, 255, 0.2); + background: #f40612; } `; @@ -144,11 +142,7 @@ export default function HomePage() { return ( {featuredMovie && ( - + @@ -157,21 +151,18 @@ export default function HomePage() { ))} {featuredMovie.title} - {featuredMovie.overview} - + {featuredMovie.overview} + - - - - - Смотреть - + Смотреть - - - В избранное - - + + diff --git a/src/app/tv/[id]/TVShowContent.tsx b/src/app/tv/[id]/TVShowContent.tsx index 6d70283..83223b3 100644 --- a/src/app/tv/[id]/TVShowContent.tsx +++ b/src/app/tv/[id]/TVShowContent.tsx @@ -1,10 +1,10 @@ -'use client'; - -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import styled from 'styled-components'; import Image from 'next/image'; -import type { TVShowDetails } from '@/lib/api'; +import type { TVShow } from '@/types/movie'; +import { tvShowsAPI, getImageUrl } from '@/lib/neoApi'; import MoviePlayer from '@/components/MoviePlayer'; +import FavoriteButton from '@/components/FavoriteButton'; const Container = styled.div` width: 100%; @@ -12,17 +12,6 @@ const Container = styled.div` margin: 0 auto; `; -const ShowInfo = styled.div` - display: grid; - grid-template-columns: 300px 1fr; - gap: 2rem; - margin-bottom: 2rem; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - } -`; - const PosterContainer = styled.div` position: relative; width: 100%; @@ -31,47 +20,42 @@ const PosterContainer = styled.div` overflow: hidden; `; -const InfoContent = styled.div` +const Info = styled.div` color: white; `; const Title = styled.h1` - font-size: 2.5rem; + font-size: 2rem; margin-bottom: 1rem; + color: white; `; const Overview = styled.p` - margin-bottom: 1.5rem; + color: rgba(255, 255, 255, 0.8); line-height: 1.6; -`; - -const Stats = styled.div` - display: flex; - gap: 2rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -`; - -const StatItem = styled.div` - span { - color: rgba(255, 255, 255, 0.6); - } -`; - -const Section = styled.section` - margin-bottom: 2rem; -`; - -const SectionTitle = styled.h2` - font-size: 1.5rem; margin-bottom: 1rem; - color: white; - padding-top: 1rem; `; -const PlayerSection = styled(Section)` - margin-top: 2rem; - min-height: 500px; +const Details = styled.div` + flex: 1; +`; + +const DetailItem = styled.div` + margin-bottom: 0.5rem; + color: rgba(255, 255, 255, 0.8); +`; + +const Label = styled.span` + color: rgba(255, 255, 255, 0.6); + margin-right: 0.5rem; +`; + +const Value = styled.span` + color: white; +`; + +const ButtonContainer = styled.div` + margin-top: 1rem; `; const PlayerContainer = styled.div` @@ -83,133 +67,121 @@ const PlayerContainer = styled.div` overflow: hidden; `; -const CastGrid = styled.div` +const Content = styled.div` display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 1rem; - margin-top: 1rem; -`; + grid-template-columns: 300px 1fr; + gap: 2rem; + margin-bottom: 2rem; -const CastCard = styled.div` - background: rgba(255, 255, 255, 0.1); - border-radius: 0.5rem; - overflow: hidden; - transition: transform 0.2s; - - &:hover { - transform: translateY(-2px); + @media (max-width: 768px) { + grid-template-columns: 1fr; } `; -const CastImageContainer = styled.div` - position: relative; - width: 100%; - height: 225px; -`; +const parseRussianDate = (dateStr: string): Date | null => { + if (!dateStr) return null; -const CastInfo = styled.div` - padding: 0.75rem; -`; + const months: { [key: string]: number } = { + 'января': 0, 'февраля': 1, 'марта': 2, 'апреля': 3, + 'мая': 4, 'июня': 5, 'июля': 6, 'августа': 7, + 'сентября': 8, 'октября': 9, 'ноября': 10, 'декабря': 11 + }; -const CastName = styled.h3` - font-size: 0.9rem; - margin-bottom: 0.25rem; - color: white; -`; + const match = dateStr.match(/(\d+)\s+([а-яё]+)\s+(\d{4})/i); + if (!match) return null; -const Character = styled.p` - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.6); -`; + const [, day, month, year] = match; + const monthIndex = months[month.toLowerCase()]; + + if (monthIndex === undefined) return null; + + return new Date(parseInt(year), monthIndex, parseInt(day)); +}; interface TVShowContentProps { tvShowId: string; - initialShow: TVShowDetails; + initialShow: TVShow; } export default function TVShowContent({ tvShowId, initialShow }: TVShowContentProps) { - const [show] = useState(initialShow); + const [show] = useState(initialShow); + const [imdbId, setImdbId] = useState(null); - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString('ru-RU', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; + useEffect(() => { + const fetchImdbId = async () => { + try { + const newImdbId = await tvShowsAPI.getImdbId(tvShowId); + if (newImdbId) { + setImdbId(newImdbId); + } + } catch (err) { + console.error('Error fetching IMDb ID:', err); + } + }; + fetchImdbId(); + }, [tvShowId]); return ( - + {show.poster_path && ( {show.name} )} - + {show.name} {show.overview} + +
+ + + + {show.first_air_date ? + (parseRussianDate(show.first_air_date)?.toLocaleDateString('ru-RU') || 'Неизвестно') + : 'Неизвестно' + } + + + + + {show.number_of_seasons || 'Неизвестно'} + + + + {show.number_of_episodes || 'Неизвестно'} + + + + {show.vote_average.toFixed(1)} + +
- - - Дата выхода: - {formatDate(show.first_air_date)} - - - Сезонов: - {show.number_of_seasons} - - - Эпизодов: - {show.number_of_episodes} - - -
-
+ + + + + - - Смотреть онлайн + {imdbId && ( - - - - {show.credits.cast.length > 0 && ( -
- В ролях - - {show.credits.cast.slice(0, 12).map(actor => ( - - - {actor.name} - - - {actor.name} - {actor.character} - - - ))} - -
)}
); diff --git a/src/app/tv/[id]/TVShowPage.tsx b/src/app/tv/[id]/TVShowPage.tsx index b1a2c47..3d27a97 100644 --- a/src/app/tv/[id]/TVShowPage.tsx +++ b/src/app/tv/[id]/TVShowPage.tsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import PageLayout from '@/components/PageLayout'; import TVShowContent from './TVShowContent'; -import type { TVShowDetails } from '@/lib/api'; +import type { TVShow } from '@/types/movie'; const Container = styled.div` width: 100%; @@ -13,7 +13,7 @@ const Container = styled.div` interface TVShowPageProps { tvShowId: string; - show: TVShowDetails | null; + show: TVShow | null; } export default function TVShowPage({ tvShowId, show }: TVShowPageProps) { @@ -21,7 +21,10 @@ export default function TVShowPage({ tvShowId, show }: TVShowPageProps) { return ( -
Сериал не найден
+
+

Сериал не найден

+

К сожалению, запрашиваемый сериал не существует или был удален.

+
); diff --git a/src/app/tv/[id]/page.tsx b/src/app/tv/[id]/page.tsx index 2904848..7001610 100644 --- a/src/app/tv/[id]/page.tsx +++ b/src/app/tv/[id]/page.tsx @@ -1,5 +1,5 @@ import TVShowPage from './TVShowPage'; -import { tvAPI } from '@/lib/api'; +import { tvShowsAPI } from '@/lib/neoApi'; interface PageProps { params: { @@ -10,8 +10,8 @@ interface PageProps { async function getData(id: string) { try { - const response = await tvAPI.getShow(id); - return { id, show: response.data }; + const response = await tvShowsAPI.getTVShow(id).then(res => res.data); + return { id, show: response }; } catch (error) { console.error('Error fetching show:', error); return { id, show: null }; diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx new file mode 100644 index 0000000..4372d9f --- /dev/null +++ b/src/components/FavoriteButton.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { favoritesAPI } from '@/lib/favoritesApi'; +import { Heart } from 'lucide-react'; +import styled from 'styled-components'; +import { toast } from 'react-hot-toast'; + +const Button = styled.button<{ $isFavorite: boolean }>` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'}; + border: none; + color: ${props => props.$isFavorite ? '#ff4444' : '#fff'}; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'}; + } + + svg { + width: 1.2rem; + height: 1.2rem; + fill: ${props => props.$isFavorite ? '#ff4444' : 'none'}; + stroke: ${props => props.$isFavorite ? '#ff4444' : '#fff'}; + transition: all 0.2s; + } +`; + +interface FavoriteButtonProps { + mediaId: string | number; + mediaType: 'movie' | 'tv'; + title: string; + posterPath: string | null; + className?: string; +} + +export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className }: FavoriteButtonProps) { + const { data: session, status } = useSession(); + const [isFavorite, setIsFavorite] = useState(false); + + // Преобразуем mediaId в строку для сравнения + const mediaIdString = mediaId.toString(); + + useEffect(() => { + const checkFavorite = async () => { + // Проверяем только если пользователь авторизован + if (status !== 'authenticated' || !session?.user?.email) return; + + try { + const response = await favoritesAPI.getFavorites(); + const favorites = response.data; + const isFav = favorites.some( + fav => fav.mediaId === mediaIdString && fav.mediaType === mediaType + ); + setIsFavorite(isFav); + } catch (error) { + console.error('Error checking favorite status:', error); + } + }; + + checkFavorite(); + }, [session?.user?.email, mediaIdString, mediaType, status]); + + const toggleFavorite = async () => { + if (!session?.user?.email) { + toast.error('Для добавления в избранное необходимо авторизоваться'); + return; + } + + try { + if (isFavorite) { + await favoritesAPI.removeFavorite(mediaIdString, mediaType); + toast.success('Удалено из избранного'); + setIsFavorite(false); + } else { + await favoritesAPI.addFavorite({ + mediaId: mediaIdString, + mediaType, + title, + posterPath: posterPath || undefined, + }); + toast.success('Добавлено в избранное'); + setIsFavorite(true); + } + } catch (error) { + console.error('Error toggling favorite:', error); + toast.error('Произошла ошибка'); + } + }; + + return ( + + ); +} diff --git a/src/components/MovieCard.tsx b/src/components/MovieCard.tsx index fb5629c..cc7a0af 100644 --- a/src/components/MovieCard.tsx +++ b/src/components/MovieCard.tsx @@ -1,47 +1,61 @@ 'use client'; +import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; import styled from 'styled-components'; import { Movie } from '@/types/movie'; +import { formatDate } from '@/lib/utils'; +import { useImageLoader } from '@/hooks/useImageLoader'; interface MovieCardProps { movie: Movie; + priority?: boolean; } -export default function MovieCard({ movie }: MovieCardProps) { - const getRatingColor = (rating: number) => { - if (rating >= 7) return '#4CAF50'; - if (rating >= 5) return '#FFC107'; - return '#F44336'; - }; - - const posterUrl = movie.poster_path - ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` - : '/placeholder.jpg'; +export default function MovieCard({ movie, priority = false }: MovieCardProps) { + const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); return ( - + {isLoading ? ( +
+
+
+ ) : imageUrl ? ( + + ) : ( +
+ No Image +
+ )} {movie.vote_average.toFixed(1)} {movie.title} - {new Date(movie.release_date).getFullYear()} + {formatDate(movie.release_date)} ); } +const getRatingColor = (rating: number) => { + if (rating >= 7) return '#4CAF50'; + if (rating >= 5) return '#FFC107'; + return '#F44336'; +}; + const Card = styled(Link)` position: relative; border-radius: 16px; diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index f20bec4..82e8bd7 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -4,6 +4,7 @@ import React from 'react'; import styled from 'styled-components'; import Link from 'next/link'; import Image from 'next/image'; +import { getImageUrl } from '@/lib/neoApi'; import { Movie, TVShow } from '@/lib/api'; const ResultsContainer = styled.div` @@ -12,7 +13,7 @@ const ResultsContainer = styled.div` padding: 1rem; `; -const ResultItem = styled(Link)` +const ResultItem = styled.div` display: flex; padding: 0.75rem; gap: 1rem; @@ -53,56 +54,43 @@ const Year = styled.span` color: rgba(255, 255, 255, 0.6); `; -const Type = styled.span` - font-size: 0.75rem; - background: rgba(255, 255, 255, 0.1); - padding: 0.25rem 0.5rem; - border-radius: 1rem; -`; - interface SearchResultsProps { results: (Movie | TVShow)[]; onItemClick: () => void; } +const getYear = (date: string | undefined | null): string => { + if (!date) return ''; + const year = date.split(' ')[2]; // Получаем год из формата "DD месяц YYYY г." + return year ? year : ''; +}; + export default function SearchResults({ results, onItemClick }: SearchResultsProps) { - const getYear = (date: string) => { - if (!date) return ''; - return new Date(date).getFullYear(); - }; - - const isMovie = (item: Movie | TVShow): item is Movie => { - return 'title' in item; - }; - return ( {results.map((item) => ( - - - {isMovie(item) - - - - {isMovie(item) ? item.title : item.name} - <Type>{isMovie(item) ? 'Фильм' : 'Сериал'}</Type> - - - {getYear(isMovie(item) ? item.release_date : item.first_air_date)} - - - + + + {item.title + + + {item.title || item.name} + + {getYear(item.release_date || item.first_air_date)} + + + + ))} ); diff --git a/src/components/SettingsContent.tsx b/src/components/SettingsContent.tsx index 53069e4..4384123 100644 --- a/src/components/SettingsContent.tsx +++ b/src/components/SettingsContent.tsx @@ -2,6 +2,7 @@ import { useSettings } from '@/hooks/useSettings'; import styled from 'styled-components'; +import { useRouter } from 'next/navigation'; const Container = styled.div` width: 100%; @@ -66,6 +67,7 @@ const SaveButton = styled.button` export default function SettingsContent() { const { settings, updateSettings } = useSettings(); + const router = useRouter(); const players = [ { @@ -87,6 +89,8 @@ export default function SettingsContent() { const handlePlayerSelect = (playerId: string) => { updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' }); + // Возвращаемся на предыдущую страницу + window.history.back(); }; return ( diff --git a/src/components/admin/MovieSearch.tsx b/src/components/admin/MovieSearch.tsx index b4f7478..711ddb1 100644 --- a/src/components/admin/MovieSearch.tsx +++ b/src/components/admin/MovieSearch.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { debounce } from 'lodash'; +import { getImageUrl } from '@/lib/neoApi'; interface Movie { id: number; @@ -13,6 +14,82 @@ interface Movie { genre_ids: number[]; } +interface MovieCardProps { + children: React.ReactNode; +} + +const MovieCard: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +interface PosterContainerProps { + children: React.ReactNode; +} + +const PosterContainer: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +interface ImageProps extends React.ImgHTMLAttributes { + src: string; + alt: string; + width: number; + height: number; +} + +const Image: React.FC = ({ src, alt, width, height, ...props }) => { + return ( + {alt} + ); +}; + +interface MovieInfoProps { + children: React.ReactNode; +} + +const MovieInfo: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +interface TitleProps { + children: React.ReactNode; +} + +const Title: React.FC = ({ children }) => { + return ( +

{children}

+ ); +}; + +interface YearProps { + children: React.ReactNode; +} + +const Year: React.FC = ({ children }) => { + return ( +

{children}

+ ); +}; + export default function MovieSearch() { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -64,31 +141,26 @@ export default function MovieSearch() { {searchResults.length > 0 && (
{searchResults.map((movie) => ( -
-
- + + {movie.title} -
-
-

{movie.title}

+ + + {movie.title} + {new Date(movie.release_date).getFullYear()}

- {new Date(movie.release_date).getFullYear()} • {movie.vote_average.toFixed(1)} ⭐ + {movie.vote_average.toFixed(1)} ⭐

{movie.overview}

-
-
+ + ))}
)} diff --git a/src/configs/auth.ts b/src/configs/auth.ts new file mode 100644 index 0000000..713c130 --- /dev/null +++ b/src/configs/auth.ts @@ -0,0 +1,73 @@ +import { AuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { compare } from 'bcrypt'; +import { connectToDatabase } from '@/lib/mongodb'; + +export const authOptions: AuthOptions = { + providers: [ + CredentialsProvider({ + name: 'credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + isAdminLogin: { label: 'isAdminLogin', type: 'boolean' } + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + throw new Error('Необходимо указать email и пароль'); + } + + const { db } = await connectToDatabase(); + const user = await db.collection('users').findOne({ email: credentials.email }); + + if (!user) { + throw new Error('Пользователь не найден'); + } + + const isValid = await compare(credentials.password, user.password); + + if (!isValid) { + throw new Error('Неверный пароль'); + } + + if (credentials.isAdminLogin === 'true' && !user.isAdmin) { + throw new Error('У вас нет прав администратора'); + } + + return { + id: user._id.toString(), + name: user.name, + email: user.email, + verified: user.verified, + isAdmin: user.isAdmin, + adminVerified: user.adminVerified + }; + } + }) + ], + pages: { + signIn: '/login', + error: '/login' + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.verified = user.verified; + token.isAdmin = user.isAdmin; + token.adminVerified = user.adminVerified; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id as string; + session.user.verified = token.verified as boolean; + session.user.isAdmin = token.isAdmin as boolean; + session.user.adminVerified = token.adminVerified as boolean; + } + return session; + } + }, + secret: process.env.NEXTAUTH_SECRET +}; diff --git a/src/hooks/useImageLoader.ts b/src/hooks/useImageLoader.ts new file mode 100644 index 0000000..1082e43 --- /dev/null +++ b/src/hooks/useImageLoader.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; +import { getImageUrl } from '@/lib/neoApi'; + +export type ImageSize = 'w500' | 'original' | 'w780' | 'w342' | 'w185' | 'w92'; + +export function useImageLoader(path: string | null, size: ImageSize = 'w500') { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [imageUrl, setImageUrl] = useState('/placeholder.jpg'); + + useEffect(() => { + if (!path) { + setImageUrl('/placeholder.jpg'); + setIsLoading(false); + return; + } + + const url = getImageUrl(path, size); + setImageUrl(url); + + const img = new Image(); + img.src = url; + + img.onload = () => { + setIsLoading(false); + setError(null); + }; + + img.onerror = (e) => { + setIsLoading(false); + setError(e as Error); + setImageUrl('/placeholder.jpg'); + }; + + return () => { + img.onload = null; + img.onerror = null; + }; + }, [path, size]); + + return { isLoading, error, imageUrl }; +} diff --git a/src/hooks/useMovies.ts b/src/hooks/useMovies.ts index 54b4826..fe2e216 100644 --- a/src/hooks/useMovies.ts +++ b/src/hooks/useMovies.ts @@ -1,8 +1,8 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { moviesAPI } from '@/lib/api'; -import type { Movie } from '@/lib/api'; +import { moviesAPI } from '@/lib/neoApi'; +import type { Movie } from '@/lib/neoApi'; export function useMovies(initialPage = 1) { const [movies, setMovies] = useState([]); @@ -12,61 +12,66 @@ export function useMovies(initialPage = 1) { 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; - }); - }, []); - + // Получаем featured фильм всегда с первой страницы const fetchFeaturedMovie = useCallback(async () => { try { const response = await moviesAPI.getPopular(1); - const filteredMovies = filterMovies(response.data.results); - if (filteredMovies.length > 0) { - const featuredMovieData = await moviesAPI.getMovie(filteredMovies[0].id); - setFeaturedMovie(featuredMovieData.data); + if (response.data.results.length > 0) { + const firstMovie = response.data.results[0]; + if (firstMovie.id) { + const movieDetails = await moviesAPI.getMovie(firstMovie.id); + setFeaturedMovie(movieDetails.data); + } } } catch (err) { console.error('Ошибка при загрузке featured фильма:', err); } - }, [filterMovies]); + }, []); + // Загружаем фильмы для текущей страницы const fetchMovies = useCallback(async (pageNum: number) => { try { setLoading(true); setError(null); + setMovies([]); // Очищаем текущие фильмы перед загрузкой новых + + console.log('Загрузка страницы:', pageNum); const response = await moviesAPI.getPopular(pageNum); - const filteredMovies = filterMovies(response.data.results); - setMovies(filteredMovies); + console.log('Получены данные:', { + page: response.data.page, + results: response.data.results.length, + totalPages: response.data.total_pages + }); + + setMovies(response.data.results); setTotalPages(response.data.total_pages); - setPage(pageNum); } catch (err) { console.error('Ошибка при загрузке фильмов:', err); setError('Произошла ошибка при загрузке фильмов'); + setMovies([]); } finally { setLoading(false); } - }, [filterMovies]); + }, []); + // Загружаем featured фильм при монтировании useEffect(() => { fetchFeaturedMovie(); }, [fetchFeaturedMovie]); + // Загружаем фильмы при изменении страницы useEffect(() => { + console.log('Изменение страницы на:', page); fetchMovies(page); }, [page, fetchMovies]); - const handlePageChange = useCallback((newPage: number) => { - window.scrollTo({ top: 0, behavior: 'smooth' }); + // Обработчик изменения страницы + const handlePageChange = useCallback(async (newPage: number) => { + if (newPage < 1 || newPage > totalPages) return; + console.log('Смена страницы на:', newPage); setPage(newPage); - }, []); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [totalPages]); return { movies, diff --git a/src/hooks/useTMDBMovies.ts b/src/hooks/useTMDBMovies.ts new file mode 100644 index 0000000..11af920 --- /dev/null +++ b/src/hooks/useTMDBMovies.ts @@ -0,0 +1,181 @@ +'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/lib/favoritesApi.ts b/src/lib/favoritesApi.ts new file mode 100644 index 0000000..a82a4b2 --- /dev/null +++ b/src/lib/favoritesApi.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; + +// Создаем экземпляр axios +const api = axios.create({ + headers: { + 'Content-Type': 'application/json' + } +}); + +export const favoritesAPI = { + // Получить все избранные + getFavorites() { + return api.get('/api/favorites'); + }, + + // Добавить в избранное + addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv'; title: string; posterPath?: string }) { + return api.post('/api/favorites', data); + }, + + // Удалить из избранного + removeFavorite(mediaId: string, mediaType: 'movie' | 'tv') { + return api.delete(`/api/favorites/${mediaId}?mediaType=${mediaType}`); + }, + + // Проверить есть ли в избранном + checkFavorite(mediaId: string, mediaType: 'movie' | 'tv') { + return api.get(`/api/favorites/check/${mediaId}?mediaType=${mediaType}`); + } +}; diff --git a/src/lib/models/Favorite.ts b/src/lib/models/Favorite.ts new file mode 100644 index 0000000..8ceab00 --- /dev/null +++ b/src/lib/models/Favorite.ts @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; + +const favoriteSchema = new mongoose.Schema({ + userId: { + type: String, + required: true + }, + mediaId: { + type: String, + required: true + }, + mediaType: { + type: String, + required: true, + enum: ['movie', 'tv'] + }, + title: { + type: String, + required: true + }, + posterPath: String, + createdAt: { + type: Date, + default: Date.now + } +}); + +// Составной индекс для уникальности комбинации userId, mediaId и mediaType +favoriteSchema.index({ userId: 1, mediaId: 1, mediaType: 1 }, { unique: true }); + +export default mongoose.models.Favorite || mongoose.model('Favorite', favoriteSchema); diff --git a/src/lib/mongodb.ts b/src/lib/mongodb.ts index a31c342..6949022 100644 --- a/src/lib/mongodb.ts +++ b/src/lib/mongodb.ts @@ -28,3 +28,35 @@ export async function connectToDatabase() { const db = client.db(); 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 new file mode 100644 index 0000000..a167f14 --- /dev/null +++ b/src/lib/neoApi.ts @@ -0,0 +1,154 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +export const neoApi = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json' + }, + timeout: 30000 // Увеличиваем таймаут до 30 секунд +}); + +// Добавляем перехватчики запросов +neoApi.interceptors.request.use( + (config) => { + if (config.params?.page) { + const page = parseInt(config.params.page); + if (isNaN(page) || page < 1) { + config.params.page = 1; + } + } + return config; + }, + (error) => { + console.error('❌ Request Error:', error); + return Promise.reject(error); + } +); + +// Добавляем перехватчики ответов +neoApi.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + console.error('❌ Response Error:', { + status: error.response?.status, + url: error.config?.url, + method: error.config?.method, + message: error.message + }); + 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}`; +}; + +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?: Genre[]; +} + +export interface MovieResponse { + page: number; + results: Movie[]; + total_pages: number; + total_results: number; +} + +export const searchAPI = { + // Мультипоиск (фильмы и сериалы) + multiSearch(query: string, page = 1) { + return neoApi.get('/search/multi', { + params: { + query, + page + }, + timeout: 30000 // Увеличиваем таймаут до 30 секунд + }); + } +}; + +export const moviesAPI = { + // Получение популярных фильмов + getPopular(page = 1) { + return neoApi.get('/movies/popular', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение данных о фильме по его ID + getMovie(id: string | number) { + return neoApi.get(`/movies/${id}`, { timeout: 30000 }); + }, + + // Поиск фильмов + searchMovies(query: string, page = 1) { + return neoApi.get('/movies/search', { + params: { + query, + page + }, + timeout: 30000 + }); + }, + + // Получение IMDB ID + getImdbId(id: string | number) { + return neoApi.get(`/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id); + } +}; + +export const tvShowsAPI = { + // Получение популярных сериалов + getPopular(page = 1) { + return neoApi.get('/tv/popular', { + params: { page }, + timeout: 30000 + }); + }, + + // Получение данных о сериале по его ID + getTVShow(id: string | number) { + return neoApi.get(`/tv/${id}`, { timeout: 30000 }); + }, + + // Поиск сериалов + searchTVShows(query: string, page = 1) { + return neoApi.get('/tv/search', { + params: { + query, + page + }, + timeout: 30000 + }); + }, + + // Получение IMDB ID + getImdbId(id: string | number) { + return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id); + } +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0434dd7..7171a51 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,10 +7,28 @@ export const validateEmail = (email: string) => { return re.test(email); }; -export const formatDate = (date: Date) => { - return new Intl.DateTimeFormat('ru-RU', { - year: 'numeric', - month: 'long', - day: 'numeric', - }).format(date); +export const formatDate = (dateString: string | Date | undefined | null) => { + if (!dateString) return 'Нет даты'; + + // Если это строка и она уже содержит "г." (формат с API), возвращаем как есть + if (typeof dateString === 'string' && dateString.includes(' г.')) { + return dateString; + } + + try { + const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + + if (isNaN(date.getTime())) { + return 'Нет даты'; + } + + return new Intl.DateTimeFormat('ru-RU', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date) + ' г.'; + } catch (error) { + console.error('Error formatting date:', error); + return 'Нет даты'; + } }; diff --git a/src/types/movie.ts b/src/types/movie.ts index 4ae335f..6e36e24 100644 --- a/src/types/movie.ts +++ b/src/types/movie.ts @@ -1,15 +1,47 @@ -export interface Movie { - _id: string; - title: string; - description: string; - year: number; - rating: number; - posterUrl: string; - genres: string[]; - director: string; - cast: string[]; - duration: number; - trailerUrl?: string; - createdAt: string; - updatedAt: 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?: Genre[]; +} + +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[]; + number_of_seasons?: number; + number_of_episodes?: number; + genres?: Genre[]; +} + +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; }