diff --git a/.env b/.env new file mode 100644 index 0000000..2d7c136 --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app +MONGODB_URI=mongodb+srv://neomoviesmail:Vfhreif1@neo-movies.nz1e2.mongodb.net/database +JWT_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c +NEXTAUTH_SECRET=eyJhdWQiOiI4ZmU3ODhlYmI5ZDAwNjZiNjQ2MWZkwNDlkNzU4ZDQxOTQwYzA3NjlhNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.x50tvcW +NEXTAUTH_URL=http://localhost:3000 +GMAIL_USER=neo.movies.mail@gmail.com +GMAIL_APP_PASSWORD=togh lhlg zadn dywe +NEXT_PUBLIC_TMDB_API_KEY=8feg88ebi9d0066b6461fa7993c23771b +NEXT_PUBLIC_TMDB_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI4ZmU3ODhlYmI5ZDAwNjZiNjQ2MWZhNzk5M2MyMzcxYiIsIm5iZiI6MTcyMzQwMTM3My4yMDgsInN1YiI6IjY2YjkwNDlkNzU4ZDQxOTQwYzA3NjlhNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.x50tvcWDdBTEhtwRb3dE7aEe9qu4sXV_qOjLMn_Vmew +NEXT_PUBLIC_LUMEX_URL=https://p.lumex.site/k1GbtOF2cX6p diff --git a/next.config.js b/next.config.js index 0cc2d9c..6e1ea18 100644 --- a/next.config.js +++ b/next.config.js @@ -21,10 +21,22 @@ const nextConfig = { hostname: 'image.tmdb.org', pathname: '/**', }, + // Локальная разработка { protocol: 'http', hostname: 'localhost', - port: '3010', + port: '3000', + pathname: '/images/**', + }, + // Продакшен на Vercel + { + protocol: 'https', + hostname: 'neomovies-api.vercel.app', + pathname: '/images/**', + }, + { + protocol: 'https', + hostname: 'neo-movies.vercel.app', pathname: '/images/**', } ], @@ -39,6 +51,18 @@ const nextConfig = { experimental: { scrollRestoration: true, }, + async headers() { + return [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, + ], + }, + ]; + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index dd7c8ca..9ea43cd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@reduxjs/toolkit": "^2.5.0", + "@vercel/analytics": "^1.0.1", "@tabler/icons-react": "^3.26.0", "@types/bcrypt": "^5.0.2", "@types/bcryptjs": "^2.4.6", diff --git a/src/app/api/mobile/auth/login/route.ts b/src/app/api/mobile/auth/login/route.ts new file mode 100644 index 0000000..47a098c --- /dev/null +++ b/src/app/api/mobile/auth/login/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; +import { compare } from 'bcryptjs'; +import { connectToDatabase } from '@/lib/mongodb'; +import jwt from 'jsonwebtoken'; + +export async function POST(req: Request) { + try { + const { email, password } = await req.json(); + + const { db } = await connectToDatabase(); + const user = await db.collection('users').findOne({ email }); + + if (!user) { + return NextResponse.json( + { error: 'Пользователь не найден' }, + { status: 404 } + ); + } + + const isPasswordValid = await compare(password, user.password); + + if (!isPasswordValid) { + return NextResponse.json( + { error: 'Неверный пароль' }, + { status: 401 } + ); + } + + if (!user.verified) { + return NextResponse.json( + { error: 'EMAIL_NOT_VERIFIED' }, + { status: 403 } + ); + } + + // Создаем JWT токен + const token = jwt.sign( + { + id: user._id.toString(), + email: user.email, + name: user.name, + verified: user.verified, + isAdmin: user.isAdmin + }, + process.env.NEXTAUTH_SECRET!, + { expiresIn: '30d' } + ); + + return NextResponse.json({ + token, + user: { + id: user._id.toString(), + email: user.email, + name: user.name, + verified: user.verified, + isAdmin: user.isAdmin + } + }); + } catch (error) { + console.error('Login error:', error); + return NextResponse.json( + { error: 'Ошибка входа' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/mobile/auth/register/route.ts b/src/app/api/mobile/auth/register/route.ts new file mode 100644 index 0000000..e8c9081 --- /dev/null +++ b/src/app/api/mobile/auth/register/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; +import { hash } from 'bcryptjs'; +import { connectToDatabase } from '@/lib/mongodb'; +import { sendVerificationEmail } from '@/lib/mailer'; + +function generateVerificationCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +export async function POST(req: Request) { + try { + const { email, password, name } = await req.json(); + + const { db } = await connectToDatabase(); + + // Проверяем, существует ли пользователь + const existingUser = await db.collection('users').findOne({ email }); + if (existingUser) { + return NextResponse.json( + { error: 'Email уже зарегистрирован' }, + { status: 400 } + ); + } + + // Хешируем пароль + const hashedPassword = await hash(password, 12); + + // Генерируем код подтверждения + const verificationCode = generateVerificationCode(); + const verificationExpires = new Date(Date.now() + 10 * 60 * 1000); // 10 минут + + // Создаем пользователя + const result = await db.collection('users').insertOne({ + email, + password: hashedPassword, + name, + verified: false, + verificationCode, + verificationExpires, + isAdmin: false, + createdAt: new Date(), + }); + + // Отправляем код подтверждения + await sendVerificationEmail(email, verificationCode); + + return NextResponse.json({ + id: result.insertedId.toString(), + email, + name, + verified: false, + isAdmin: false + }); + } catch (error) { + console.error('Registration error:', error); + return NextResponse.json( + { error: 'Ошибка регистрации' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/mobile/auth/resend-code/route.ts b/src/app/api/mobile/auth/resend-code/route.ts new file mode 100644 index 0000000..3bda650 --- /dev/null +++ b/src/app/api/mobile/auth/resend-code/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; +import { sendVerificationEmail } from '@/lib/mailer'; + +function generateVerificationCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +export async function POST(req: Request) { + try { + const { email } = await req.json(); + + const { db } = await connectToDatabase(); + const user = await db.collection('users').findOne({ email }); + + if (!user) { + return NextResponse.json( + { error: 'Пользователь не найден' }, + { status: 404 } + ); + } + + if (user.verified) { + return NextResponse.json( + { error: 'Email уже подтвержден' }, + { status: 400 } + ); + } + + // Генерируем новый код + const verificationCode = generateVerificationCode(); + const verificationExpires = new Date(Date.now() + 10 * 60 * 1000); // 10 минут + + // Обновляем код в базе + await db.collection('users').updateOne( + { _id: user._id }, + { + $set: { + verificationCode, + verificationExpires + } + } + ); + + // Отправляем новый код + await sendVerificationEmail(email, verificationCode); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Resend code error:', error); + return NextResponse.json( + { error: 'Ошибка отправки кода' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/mobile/auth/verify/route.ts b/src/app/api/mobile/auth/verify/route.ts new file mode 100644 index 0000000..04b4549 --- /dev/null +++ b/src/app/api/mobile/auth/verify/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; +import jwt from 'jsonwebtoken'; + +export async function POST(req: Request) { + try { + const { email, code } = await req.json(); + + const { db } = await connectToDatabase(); + const user = await db.collection('users').findOne({ + email, + verificationCode: code, + verificationExpires: { $gt: new Date() } + }); + + if (!user) { + return NextResponse.json( + { error: 'Неверный код или код истек' }, + { status: 400 } + ); + } + + // Подтверждаем email + await db.collection('users').updateOne( + { _id: user._id }, + { + $set: { verified: true }, + $unset: { verificationCode: "", verificationExpires: "" } + } + ); + + // Создаем JWT токен + const token = jwt.sign( + { + id: user._id.toString(), + email: user.email, + name: user.name, + verified: true, + isAdmin: user.isAdmin + }, + process.env.NEXTAUTH_SECRET!, + { expiresIn: '30d' } + ); + + return NextResponse.json({ + token, + user: { + id: user._id.toString(), + email: user.email, + name: user.name, + verified: true, + isAdmin: user.isAdmin + } + }); + } catch (error) { + console.error('Verification error:', error); + return NextResponse.json( + { error: 'Ошибка верификации' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/movies/[id]/external-ids/route.ts b/src/app/api/movies/[id]/external-ids/route.ts new file mode 100644 index 0000000..aa66bd7 --- /dev/null +++ b/src/app/api/movies/[id]/external-ids/route.ts @@ -0,0 +1,45 @@ +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/categories/page.tsx b/src/app/categories/page.tsx new file mode 100644 index 0000000..d6f6de2 --- /dev/null +++ b/src/app/categories/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { moviesAPI } from '@/lib/api'; +import MovieCard from '@/components/MovieCard'; +import { Movie } from '@/lib/api'; + +interface Genre { + id: number; + name: string; +} + +// Styled Components +const Container = styled.div` + max-width: 1280px; + margin: 0 auto; + padding: 2rem 1rem; +`; + +const Title = styled.h1` + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 1.5rem; + color: #fff; +`; + +const GenreButtons = styled.div` + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + flex-wrap: wrap; +`; + +const GenreButton = styled.button<{ $active?: boolean }>` + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'}; + color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'}; + border: none; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'}; + } +`; + +const MovieGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1.5rem; + + @media (min-width: 640px) { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } +`; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +`; + +const Spinner = styled.div` + width: 40px; + height: 40px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: #3182ce; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +`; + +const ErrorMessage = styled.div` + color: #fc8181; + text-align: center; + padding: 2rem; + background: rgba(252, 129, 129, 0.1); + border-radius: 0.5rem; + margin: 2rem 0; +`; + +export default function CategoriesPage() { + const [genres, setGenres] = useState([]); + const [selectedGenre, setSelectedGenre] = useState(null); + const [movies, setMovies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Загрузка жанров при монтировании + useEffect(() => { + const fetchGenres = async () => { + setError(null); + try { + console.log('Fetching genres...'); + const response = await moviesAPI.getGenres(); + console.log('Genres response:', response.data); + + if (response.data.genres && response.data.genres.length > 0) { + setGenres(response.data.genres); + setSelectedGenre(response.data.genres[0].id); + } else { + setError('Не удалось загрузить жанры'); + } + } catch (error) { + console.error('Error fetching genres:', error); + setError('Ошибка при загрузке жанров'); + } + }; + fetchGenres(); + }, []); + + // Загрузка фильмов при изменении выбранного жанра + useEffect(() => { + const fetchMoviesByGenre = async () => { + if (!selectedGenre) return; + + setLoading(true); + setError(null); + try { + console.log('Fetching movies for genre:', selectedGenre); + const response = await moviesAPI.getMoviesByGenre(selectedGenre); + console.log('Movies response:', { + total: response.data.results?.length, + first: response.data.results?.[0] + }); + + if (response.data.results) { + setMovies(response.data.results); + } else { + setError('Не удалось загрузить фильмы'); + } + } catch (error) { + console.error('Error fetching movies:', error); + setError('Ошибка при загрузке фильмов'); + } finally { + setLoading(false); + } + }; + + fetchMoviesByGenre(); + }, [selectedGenre]); + + if (error) { + return ( + + Категории фильмов + {error} + + ); + } + + return ( + + Категории фильмов + + {/* Кнопки жанров */} + + {genres.map((genre) => ( + setSelectedGenre(genre.id)} + > + {genre.name} + + ))} + + + {/* Сетка фильмов */} + {loading ? ( + + + + ) : ( + + {movies.map((movie) => ( + + ))} + + )} + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 68578eb..7a93cec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Inter } from 'next/font/google'; import './globals.css'; import { ClientLayout } from '@/components/ClientLayout'; import type { Metadata } from 'next'; +import { Analytics } from "@vercel/analytics/react"; const inter = Inter({ subsets: ['latin', 'cyrillic'] }); @@ -22,6 +23,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx index 6305341..69c3b86 100644 --- a/src/app/movie/[id]/MovieContent.tsx +++ b/src/app/movie/[id]/MovieContent.tsx @@ -2,12 +2,13 @@ import { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { moviesAPI } from '@/lib/api'; +import { moviesAPI } from '@/lib/neoApi'; 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'; +import { formatDate } from '@/lib/utils'; declare global { interface Window { @@ -137,9 +138,9 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp useEffect(() => { const fetchImdbId = async () => { try { - const newImdbId = await moviesAPI.getImdbId(movieId); - if (newImdbId) { - setImdbId(newImdbId); + const { data } = await moviesAPI.getMovie(movieId); + if (data?.imdb_id) { + setImdbId(data.imdb_id); } } catch (err) { console.error('Error fetching IMDb ID:', err); @@ -164,7 +165,7 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp Рейтинг: {movie.vote_average.toFixed(1)} Длительность: {movie.runtime} мин. - Дата выхода: {new Date(movie.release_date).toLocaleDateString('ru-RU')} + Дата выхода: {formatDate(movie.release_date)} {movie.genres.map(genre => ( diff --git a/src/components/MoviePlayer.tsx b/src/components/MoviePlayer.tsx index 13b916c..c4312cf 100644 --- a/src/components/MoviePlayer.tsx +++ b/src/components/MoviePlayer.tsx @@ -106,11 +106,11 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr setError(null); if (!imdbId) { - const newImdbId = await moviesAPI.getImdbId(id); - if (!newImdbId) { + const { data } = await moviesAPI.getMovie(id); + if (!data?.imdb_id) { throw new Error('IMDb ID не найден'); } - imdbId = newImdbId; + imdbId = data.imdb_id; } } catch (err) { console.error('Error fetching IMDb ID:', err); diff --git a/src/lib/neoApi.ts b/src/lib/neoApi.ts index a167f14..9647b68 100644 --- a/src/lib/neoApi.ts +++ b/src/lib/neoApi.ts @@ -118,7 +118,7 @@ export const moviesAPI = { // Получение IMDB ID getImdbId(id: string | number) { - return neoApi.get(`/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id); + return neoApi.get(`/movies/${id}/external_ids`, { timeout: 30000 }).then(res => res.data.imdb_id); } }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7171a51..f3c872b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,9 +16,30 @@ export const formatDate = (dateString: string | Date | undefined | null) => { } try { - const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + let date: Date; + + if (typeof dateString === 'string') { + // Пробуем разные форматы даты + if (dateString.includes('T')) { + // ISO формат + date = new Date(dateString); + } else if (dateString.includes('-')) { + // YYYY-MM-DD формат + const [year, month, day] = dateString.split('-').map(Number); + date = new Date(year, month - 1, day); + } else if (dateString.includes('.')) { + // DD.MM.YYYY формат + const [day, month, year] = dateString.split('.').map(Number); + date = new Date(year, month - 1, day); + } else { + date = new Date(dateString); + } + } else { + date = dateString; + } if (isNaN(date.getTime())) { + console.error('Invalid date:', dateString); return 'Нет даты'; } @@ -28,7 +49,7 @@ export const formatDate = (dateString: string | Date | undefined | null) => { day: 'numeric', }).format(date) + ' г.'; } catch (error) { - console.error('Error formatting date:', error); + console.error('Error formatting date:', error, dateString); return 'Нет даты'; } };