fix: исправлен плеер для фильмов

This commit is contained in:
Foxix
2025-02-09 21:22:57 +02:00
parent 660d4ad6dc
commit 37764dce4d
14 changed files with 553 additions and 12 deletions

10
.env Normal file
View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

192
src/app/categories/page.tsx Normal file
View File

@@ -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<Genre[]>([]);
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Container>
<Title>Категории фильмов</Title>
<ErrorMessage>{error}</ErrorMessage>
</Container>
);
}
return (
<Container>
<Title>Категории фильмов</Title>
{/* Кнопки жанров */}
<GenreButtons>
{genres.map((genre) => (
<GenreButton
key={genre.id}
$active={selectedGenre === genre.id}
onClick={() => setSelectedGenre(genre.id)}
>
{genre.name}
</GenreButton>
))}
</GenreButtons>
{/* Сетка фильмов */}
{loading ? (
<LoadingContainer>
<Spinner />
</LoadingContainer>
) : (
<MovieGrid>
{movies.map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
</MovieGrid>
)}
</Container>
);
}

View File

@@ -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({
</head>
<body className={inter.className} suppressHydrationWarning>
<ClientLayout>{children}</ClientLayout>
<Analytics />
</body>
</html>
);

View File

@@ -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
<Info>
<InfoItem>Рейтинг: {movie.vote_average.toFixed(1)}</InfoItem>
<InfoItem>Длительность: {movie.runtime} мин.</InfoItem>
<InfoItem>Дата выхода: {new Date(movie.release_date).toLocaleDateString('ru-RU')}</InfoItem>
<InfoItem>Дата выхода: {formatDate(movie.release_date)}</InfoItem>
</Info>
<GenreList>
{movie.genres.map(genre => (

View File

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

View File

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

View File

@@ -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 'Нет даты';
}
};