Authorization, favorites and players have been moved to the API server

This commit is contained in:
2025-07-07 18:22:51 +03:00
parent ae85eda411
commit 4aad0c8d48
42 changed files with 500 additions and 1376 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.env
.env.local
.next

View File

@@ -26,12 +26,11 @@ Neo Movies - это современная веб-платформа постр
- React 18 - React 18
- TypeScript - TypeScript
- Styled Components - Styled Components
- NextAuth.js - JWT-based authentication (custom)
- **Backend:** - **Backend:**
- Next.js - Node.js + Express (neomovies-api)
- MongoDB - MongoDB (native driver)
- Mongoose
- **Дополнительно:** - **Дополнительно:**
- ESLint - ESLint
@@ -54,31 +53,9 @@ npm install
3. Создайте файл `.env` и добавьте следующие переменные: 3. Создайте файл `.env` и добавьте следующие переменные:
```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
NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
# JWT конфигурация NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
JWT_SECRET=your_jwt_secret
# Lumex Player URL
NEXT_PUBLIC_LUMEX_URL=your_lumex_player_url
#Alloha token
ALLOHA_TOKEN=your_token
``` ```
@@ -93,7 +70,7 @@ npm start
``` ```
Приложение будет доступно по адресу [http://localhost:3000](http://localhost:3000) Приложение будет доступно по адресу [http://localhost:3000](http://localhost:3000)
## API ## API (neomovies-api)
Приложение использует отдельный API сервер. API предоставляет следующие возможности: Приложение использует отдельный API сервер. API предоставляет следующие возможности:
@@ -102,21 +79,16 @@ npm start
- Оптимизированная загрузка изображений - Оптимизированная загрузка изображений
- Кэширование запросов - Кэширование запросов
### 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 ### Gmail App Password
1. Включите двухфакторную аутентификацию в аккаунте Google 1. Включите двухфакторную аутентификацию в аккаунте Google
2. Перейдите в настройки безопасности 2. Перейдите в настройки безопасности
3. Создайте пароль приложения 3. Создайте пароль приложения
4. Используйте этот пароль в GMAIL_APP_PASSWORD 4. Используйте этот пароль в GMAIL_APP_PASSWORD
Backend `.env` пример смотрите в репозитории [neomovies-api](https://gitlab.com/foxixus/neomovies-api).
---
## Структура проекта ## Структура проекта
``` ```

View File

@@ -10,13 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@vercel/analytics": "^1.0.1",
"@tabler/icons-react": "^3.26.0", "@tabler/icons-react": "^3.26.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.13",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
"@vercel/analytics": "^1.0.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -27,7 +27,6 @@
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"next": "15.1.2", "next": "15.1.2",
"next-auth": "^4.24.11",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { signIn } from 'next-auth/react'; import { api } from '@/lib/api';
import { AxiosError } from 'axios';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -91,29 +92,27 @@ export default function AdminLoginClient() {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await signIn('credentials', { const response = await api.post('/auth/login', {
email, email,
password, password,
isAdminLogin: 'true',
redirect: false,
}); });
if (result?.error) { const { token, user } = response.data;
switch (result.error) {
case 'NOT_AN_ADMIN': if (user?.role !== 'admin') {
setError('У вас нет прав администратора'); setError('У вас нет прав администратора.');
break; setIsLoading(false);
case 'EMAIL_NOT_VERIFIED': return;
setError('Пожалуйста, подтвердите свой email');
break;
default:
setError('Неверный email или пароль');
}
} else {
router.push('/admin');
} }
} catch (error) {
setError('Произошла ошибка при входе'); localStorage.setItem('token', token);
localStorage.setItem('userName', user.name);
localStorage.setItem('userEmail', user.email);
router.push('/admin');
} catch (err) {
const axiosError = err as AxiosError<{ message: string }>;
setError(axiosError.response?.data?.message || 'Неверный email или пароль');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server';
import { User } from '@/models';
import { connectDB } from '@/lib/db';
export async function POST(req: Request) {
try {
const { email, secret } = await req.json();
// Проверяем секретный ключ
const adminSecret = process.env.ADMIN_SECRET;
if (!adminSecret || secret !== adminSecret) {
return NextResponse.json(
{ error: 'Неверный секретный ключ' },
{ status: 403 }
);
}
await connectDB();
const user = await User.findOne({ email });
if (!user) {
return NextResponse.json(
{ error: 'Пользователь не найден' },
{ status: 404 }
);
}
// Назначаем пользователя администратором
user.isAdmin = true;
await user.save();
return NextResponse.json({
success: true,
message: 'Пользователь успешно назначен администратором'
});
} catch (error) {
console.error('Error creating admin:', error);
return NextResponse.json(
{ error: 'Произошла ошибка при назначении администратора' },
{ status: 500 }
);
}
}

View File

@@ -1,28 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { Movie } from '@/models';
import { connectDB } from '@/lib/db';
export async function GET() {
try {
await connectDB();
const session = await getServerSession(authOptions);
if (!session?.user?.isAdmin) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const movies = await Movie.find().sort({ createdAt: -1 });
return NextResponse.json(movies);
} catch (error) {
console.error('Error fetching movies:', error);
return NextResponse.json(
{ error: 'Failed to fetch movies' },
{ status: 500 }
);
}
}

View File

@@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { Movie } from '@/models';
import { connectDB } from '@/lib/db';
export async function POST(request: Request) {
try {
await connectDB();
const session = await getServerSession(authOptions);
if (!session?.user?.isAdmin) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { movieId } = await request.json();
if (!movieId) {
return NextResponse.json(
{ error: 'Movie ID is required' },
{ status: 400 }
);
}
const movie = await Movie.findById(movieId);
if (!movie) {
return NextResponse.json(
{ error: 'Movie not found' },
{ status: 404 }
);
}
movie.isVisible = !movie.isVisible;
await movie.save();
return NextResponse.json({ success: true, movie });
} catch (error) {
console.error('Error toggling movie visibility:', error);
return NextResponse.json(
{ error: 'Failed to toggle movie visibility' },
{ status: 500 }
);
}
}

View File

@@ -1,42 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { User } from '@/models';
import { connectDB } from '@/lib/db';
import { sendVerificationEmail } from '@/lib/email';
import { generateVerificationToken } from '@/lib/utils';
export async function POST(req: Request) {
try {
const { email } = await req.json();
await connectDB();
const user = await User.findOne({ email });
if (!user || !user.isAdmin) {
return NextResponse.json(
{ error: 'Доступ запрещен' },
{ status: 403 }
);
}
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
const token = generateVerificationToken();
await sendVerificationEmail(email, token);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error sending verification email:', error);
return NextResponse.json(
{ error: 'Failed to send verification email' },
{ status: 500 }
);
}
}

View File

@@ -1,56 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { User } from '@/models';
import { connectDB } from '@/lib/db';
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.isAdmin) {
return NextResponse.json(
{ error: 'Доступ запрещен' },
{ status: 403 }
);
}
const { userId } = await req.json();
await connectDB();
const targetUser = await User.findById(userId);
if (!targetUser) {
return NextResponse.json(
{ error: 'Пользователь не найден' },
{ status: 404 }
);
}
// Проверяем, что это не последний администратор
if (targetUser.isAdmin) {
const adminCount = await User.countDocuments({ isAdmin: true });
if (adminCount <= 1) {
return NextResponse.json(
{ error: 'Нельзя отозвать права у последнего администратора' },
{ status: 400 }
);
}
}
// Переключаем статус администратора
targetUser.isAdmin = !targetUser.isAdmin;
await targetUser.save();
return NextResponse.json({
success: true,
isAdmin: targetUser.isAdmin,
});
} catch (error) {
console.error('Error toggling admin status:', error);
return NextResponse.json(
{ error: 'Произошла ошибка при изменении прав администратора' },
{ status: 500 }
);
}
}

View File

@@ -1,56 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { User } from '@/models';
import { connectDB } from '@/lib/db';
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.isAdmin) {
return NextResponse.json(
{ error: 'Доступ запрещен' },
{ status: 403 }
);
}
const { userId } = await req.json();
await connectDB();
const targetUser = await User.findById(userId);
if (!targetUser) {
return NextResponse.json(
{ error: 'Пользователь не найден' },
{ status: 404 }
);
}
// Проверяем, что это не последний администратор
if (targetUser.isAdmin) {
const adminCount = await User.countDocuments({ isAdmin: true });
if (adminCount <= 1) {
return NextResponse.json(
{ error: 'Нельзя отозвать права у последнего администратора' },
{ status: 400 }
);
}
}
// Переключаем статус администратора
targetUser.isAdmin = !targetUser.isAdmin;
await targetUser.save();
return NextResponse.json({
success: true,
isAdmin: targetUser.isAdmin,
});
} catch (error) {
console.error('Error toggling admin status:', error);
return NextResponse.json(
{ error: 'Произошла ошибка при изменении прав администратора' },
{ status: 500 }
);
}
}

View File

@@ -1,42 +0,0 @@
import { NextResponse } from 'next/server';
import { User } from '@/models';
import { connectDB } from '@/lib/db';
export async function POST(req: Request) {
try {
const { email, code } = await req.json();
await connectDB();
const user = await User.findOne({ email });
if (!user || !user.isAdmin) {
return NextResponse.json(
{ error: 'Доступ запрещен' },
{ status: 403 }
);
}
// Проверяем код
if (!user.adminVerificationCode ||
user.adminVerificationCode.code !== code ||
new Date() > new Date(user.adminVerificationCode.expiresAt)) {
return NextResponse.json(
{ error: 'Неверный или устаревший код подтверждения' },
{ status: 400 }
);
}
// Очищаем код после успешной проверки
user.adminVerificationCode = undefined;
await user.save();
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error verifying code:', error);
return NextResponse.json(
{ error: 'Произошла ошибка при проверке кода' },
{ status: 500 }
);
}
}

View File

@@ -1,38 +0,0 @@
import { NextResponse } from 'next/server';
export const revalidate = 0; // always fresh
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const imdbId = searchParams.get('imdb_id');
const tmdbId = searchParams.get('tmdb_id');
if (!imdbId && !tmdbId) {
return NextResponse.json({ error: 'imdb_id or tmdb_id query param is required' }, { status: 400 });
}
const token = process.env.ALLOHA_TOKEN;
if (!token) {
return NextResponse.json({ error: 'Server misconfiguration: ALLOHA_TOKEN missing' }, { status: 500 });
}
const idParam = imdbId ? `imdb=${encodeURIComponent(imdbId)}` : `tmdb=${encodeURIComponent(tmdbId!)}`;
const apiUrl = `https://api.alloha.tv/?token=${token}&${idParam}`;
const apiRes = await fetch(apiUrl, { next: { revalidate: 0 } });
if (!apiRes.ok) {
return NextResponse.json({ error: 'Failed to fetch from Alloha' }, { status: apiRes.status });
}
const json = await apiRes.json();
if (json.status !== 'success' || !json.data?.iframe) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
return NextResponse.json({ iframe: json.data.iframe });
} catch (e) {
console.error('Alloha API route error:', e);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -1,98 +0,0 @@
import NextAuth, { DefaultSession } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { compare } from 'bcrypt';
import { connectToDatabase } from '@/lib/mongodb';
// Расширяем тип User в сессии
declare module 'next-auth' {
interface Session {
user: {
id: string;
name: string;
email: string;
verified: boolean;
isAdmin: boolean;
adminVerified?: boolean;
} & DefaultSession['user']
}
}
const handler = NextAuth({
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 isPasswordValid = await compare(credentials.password, user.password);
if (!isPasswordValid) {
throw new Error('Неверный пароль');
}
// Проверяем верификацию
if (!user.verified) {
throw new Error('EMAIL_NOT_VERIFIED');
}
// Если это попытка входа в админ-панель
if (credentials.isAdminLogin === 'true') {
// Проверяем, является ли пользователь админом
if (!user.isAdmin) {
throw new Error('NOT_AN_ADMIN');
}
}
return {
id: user._id.toString(),
email: user.email,
name: user.name,
verified: user.verified,
isAdmin: user.isAdmin,
adminVerified: credentials.isAdminLogin === 'true'
};
}
})
],
pages: {
signIn: '/login',
error: '/login'
},
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id;
token.verified = user.verified;
token.isAdmin = user.isAdmin;
token.adminVerified = user.adminVerified;
}
return token;
},
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,
});
export { handler as GET, handler as POST };

View File

@@ -1,23 +0,0 @@
import { NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongodb';
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 });
}
return NextResponse.json({ verified: user.verified ?? false });
} catch (error) {
console.error('Error checking verification status:', error);
return NextResponse.json(
{ error: 'Внутренняя ошибка сервера' },
{ status: 500 }
);
}
}

View File

@@ -1,58 +0,0 @@
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 минут
// Создаем пользователя
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({
success: true,
email,
message: 'Пользователь успешно зарегистрирован',
});
} catch {
return NextResponse.json(
{ message: 'Ошибка при регистрации' },
{ status: 500 }
);
}
}

View File

@@ -1,48 +0,0 @@
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 }
);
}
// Генерируем новый код
const verificationCode = generateVerificationCode();
const verificationExpires = new Date(Date.now() + 10 * 60 * 1000); // 10 минут
// Обновляем код в базе
await db.collection('users').updateOne(
{ email },
{
$set: {
verificationCode,
verificationExpires,
},
}
);
// Отправляем новый код
await sendVerificationEmail(email, verificationCode);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ message: 'Ошибка при отправке кода' },
{ status: 500 }
);
}
}

View File

@@ -1,51 +0,0 @@
import { NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongodb';
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 });
if (!user) {
return NextResponse.json(
{ error: 'Пользователь не найден' },
{ status: 404 }
);
}
if (user.verificationCode !== code) {
return NextResponse.json(
{ error: 'Неверный код подтверждения' },
{ status: 400 }
);
}
if (user.verificationExpires < new Date()) {
return NextResponse.json(
{ error: 'Код подтверждения истек' },
{ status: 400 }
);
}
// Подтверждаем аккаунт
await db.collection('users').updateOne(
{ email },
{
$set: {
verified: true,
verificationCode: null,
verificationExpires: null,
},
}
);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ message: 'Ошибка при подтверждении' },
{ status: 500 }
);
}
}

View File

@@ -1,41 +0,0 @@
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 });
}
}

View File

@@ -1,32 +0,0 @@
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 });
}
}

View File

@@ -1,88 +0,0 @@
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 });
}
}

View File

@@ -1,66 +0,0 @@
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

@@ -1,61 +0,0 @@
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

@@ -1,56 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation';
import { favoritesAPI } from '@/lib/favoritesApi'; import { favoritesAPI } from '@/lib/favoritesApi';
import { getImageUrl } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi';
@@ -87,14 +87,15 @@ interface Favorite {
} }
export default function FavoritesPage() { export default function FavoritesPage() {
const { data: session } = useSession();
const [favorites, setFavorites] = useState<Favorite[]>([]); const [favorites, setFavorites] = useState<Favorite[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => { useEffect(() => {
const fetchFavorites = async () => { const fetchFavorites = async () => {
if (!session?.user) { const token = localStorage.getItem('token');
setLoading(false); if (!token) {
router.push('/login');
return; return;
} }
@@ -102,14 +103,19 @@ export default function FavoritesPage() {
const response = await favoritesAPI.getFavorites(); const response = await favoritesAPI.getFavorites();
setFavorites(response.data); setFavorites(response.data);
} catch (error) { } catch (error) {
console.error('Error fetching favorites:', error); console.error('Failed to fetch favorites:', error);
// If token is invalid, clear it and redirect to login
localStorage.removeItem('token');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
router.push('/login');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchFavorites(); fetchFavorites();
}, [session?.user]); }, [router]);
if (loading) { if (loading) {
return ( return (
@@ -120,16 +126,7 @@ export default function FavoritesPage() {
); );
} }
if (!session?.user) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>
Для доступа к избранному необходимо авторизоваться
</EmptyState>
</Container>
);
}
if (favorites.length === 0) { if (favorites.length === 0) {
return ( return (

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { signIn } from 'next-auth/react'; import { useAuth } from '../../hooks/useAuth';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
const Container = styled.div` const Container = styled.div`
@@ -173,6 +173,14 @@ export default function LoginClient() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const router = useRouter(); const router = useRouter();
const { login, register } = useAuth();
// Redirect authenticated users away from /login
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.replace('/');
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -180,38 +188,11 @@ export default function LoginClient() {
try { try {
if (isLogin) { if (isLogin) {
const result = await signIn('credentials', { await login(email, password);
redirect: false,
email,
password,
});
if (result?.error) {
if (result.error === 'EMAIL_NOT_VERIFIED') {
router.push(`/verify?email=${encodeURIComponent(email)}`);
return;
}
throw new Error(result.error);
}
router.push('/');
} else { } else {
const response = await fetch('/api/auth/register', { await register(email, password, name);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Ошибка при регистрации');
}
const data = await response.json();
// Сохраняем пароль для автовхода после верификации // Сохраняем пароль для автовхода после верификации
localStorage.setItem('password', password); localStorage.setItem('password', password);
router.push(`/verify?email=${encodeURIComponent(email)}`); router.push(`/verify?email=${encodeURIComponent(email)}`);
} }
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,129 @@
'use client';
import dynamic from 'next/dynamic';
import styled from 'styled-components';
const LoginClient = dynamic(() => import('./LoginClient'), {
ssr: false
});
export default function LoginPage() {
return (
<Container>
<GlowingBackground>
<Glow1 />
<Glow2 />
<Glow3 />
</GlowingBackground>
<Content>
<Logo>
<span>Neo</span> Movies
</Logo>
<GlassCard>
<LoginClient />
</GlassCard>
</Content>
</Container>
);
}
const Container = styled.div`
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background-color: #0a0a0a;
overflow: hidden;
`;
const Content = styled.main`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 1200px;
padding: 2rem;
position: relative;
z-index: 1;
`;
const Logo = styled.h1`
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 2rem;
color: white;
text-align: center;
span {
color: #2196f3;
}
`;
const GlassCard = styled.div`
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 3rem;
border-radius: 24px;
width: 100%;
max-width: 500px;
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.3),
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
margin: 0 auto;
`;
const GlowingBackground = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
`;
const Glow = styled.div`
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.3;
animation: float 20s infinite ease-in-out;
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-30px, 30px); }
}
`;
const Glow1 = styled(Glow)`
background: #2196f3;
width: 600px;
height: 600px;
top: -200px;
left: -200px;
animation-delay: 0s;
`;
const Glow2 = styled(Glow)`
background: #9c27b0;
width: 500px;
height: 500px;
bottom: -150px;
right: -150px;
animation-delay: -5s;
`;
const Glow3 = styled(Glow)`
background: #00bcd4;
width: 400px;
height: 400px;
bottom: 100px;
left: 30%;
animation-delay: -10s;
`;

View File

@@ -9,14 +9,13 @@ interface PageProps {
} }
// Генерация метаданных для страницы // Генерация метаданных для страницы
export async function generateMetadata( export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
props: { params: { id: string }} const { params } = await props;
): Promise<Metadata> {
// В Next.js 14, нужно сначала получить данные фильма, // В Next.js 14, нужно сначала получить данные фильма,
// а затем использовать их для метаданных // а затем использовать их для метаданных
try { try {
// Получаем id для использования в запросе // Получаем id для использования в запросе
const movieId = props.params.id; const movieId = params.id;
// Запрашиваем данные фильма // Запрашиваем данные фильма
const { data: movie } = await moviesAPI.getMovie(movieId); const { data: movie } = await moviesAPI.getMovie(movieId);

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import { useSession } from 'next-auth/react'; import { useAuth } from '@/hooks/useAuth';
import styled from 'styled-components'; import styled from 'styled-components';
import GlassCard from '@/components/GlassCard'; import GlassCard from '@/components/GlassCard';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
const Container = styled.div` const Container = styled.div`
min-height: 100vh; min-height: 100vh;
@@ -70,16 +70,28 @@ const SignOutButton = styled.button`
`; `;
export default function ProfilePage() { export default function ProfilePage() {
const { data: session, status } = useSession(); const { logout } = useAuth();
const router = useRouter(); const router = useRouter();
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (status === 'unauthenticated') { const token = localStorage.getItem('token');
if (!token) {
router.push('/login'); router.push('/login');
} else {
setUserName(localStorage.getItem('userName'));
setUserEmail(localStorage.getItem('userEmail'));
setLoading(false);
} }
}, [status, router]); }, [router]);
if (status === 'loading') { const handleSignOut = () => {
logout();
};
if (loading) {
return ( return (
<Container> <Container>
<Content> <Content>
@@ -91,7 +103,7 @@ export default function ProfilePage() {
); );
} }
if (!session) { if (!userName) {
return null; return null;
} }
@@ -101,14 +113,12 @@ export default function ProfilePage() {
<GlassCard> <GlassCard>
<ProfileHeader> <ProfileHeader>
<Avatar> <Avatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</Avatar> </Avatar>
<Name>{session.user?.name}</Name> <Name>{userName}</Name>
<Email>{session.user?.email}</Email> <Email>{userEmail}</Email>
<SignOutButton onClick={handleSignOut}>Выйти</SignOutButton>
</ProfileHeader> </ProfileHeader>
<SignOutButton onClick={() => router.push('/settings')}>
Настройки
</SignOutButton>
</GlassCard> </GlassCard>
</Content> </Content>
</Container> </Container>

View File

@@ -1,32 +0,0 @@
'use client';
import { ThemeProvider } from 'styled-components';
import { SessionProvider } from 'next-auth/react';
const theme = {
colors: {
primary: '#2196f3',
background: '#121212',
surface: '#1e1e1e',
text: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.7)',
error: '#f44336',
success: '#4caf50',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
};
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<SessionProvider refetchInterval={0} refetchOnWindowFocus={false}>
{children}
</SessionProvider>
</ThemeProvider>
);
}

View File

@@ -3,7 +3,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react'; import { useAuth } from '../../hooks/useAuth';
import { authAPI } from '@/lib/authApi';
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
@@ -40,7 +41,7 @@ const CodeInput = styled.input`
&:focus { &:focus {
outline: none; outline: none;
border-color: ${({ theme }) => theme.colors.primary}; border-color: #2196f3;
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1); box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
} }
@@ -82,7 +83,7 @@ const VerifyButton = styled.button`
const ResendButton = styled.button` const ResendButton = styled.button`
background: none; background: none;
border: none; border: none;
color: ${({ theme }) => theme.colors.primary}; color: #2196f3;
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
@@ -110,6 +111,7 @@ export function VerificationClient({ email }: { email: string }) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
const router = useRouter(); const router = useRouter();
const { verifyCode, login } = useAuth();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
@@ -134,32 +136,7 @@ export function VerificationClient({ email }: { email: string }) {
setError(''); setError('');
try { try {
const response = await fetch('/api/auth/verify', { await verifyCode(code);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, code }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Ошибка верификации');
}
// Выполняем вход после успешной верификации
const result = await signIn('credentials', {
redirect: false,
email,
password: localStorage.getItem('password'),
});
if (result?.error) {
throw new Error(result.error);
}
router.push('/');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка'); setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally { } finally {
@@ -169,18 +146,7 @@ export function VerificationClient({ email }: { email: string }) {
const handleResend = async () => { const handleResend = async () => {
try { try {
const response = await fetch('/api/auth/resend-code', { await authAPI.resendCode(email);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
throw new Error('Не удалось отправить код');
}
setCountdown(60); setCountdown(60);
} catch (err) { } catch (err) {
setError('Не удалось отправить код'); setError('Не удалось отправить код');

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import StyledComponentsRegistry from '@/lib/registry'; import StyledComponentsRegistry from '@/lib/registry';
import Navbar from './Navbar'; import Navbar from './Navbar';
@@ -16,14 +16,12 @@ const theme = {
export function ClientLayout({ children }: { children: React.ReactNode }) { export function ClientLayout({ children }: { children: React.ReactNode }) {
return ( return (
<SessionProvider> <StyledComponentsRegistry>
<StyledComponentsRegistry>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Navbar /> <Navbar />
{children} {children}
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</ThemeProvider> </ThemeProvider>
</StyledComponentsRegistry> </StyledComponentsRegistry>
</SessionProvider>
); );
} }

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { favoritesAPI } from '@/lib/favoritesApi'; import { favoritesAPI } from '@/lib/favoritesApi';
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -39,7 +38,7 @@ interface FavoriteButtonProps {
} }
export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className }: FavoriteButtonProps) { export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className }: FavoriteButtonProps) {
const { data: session, status } = useSession(); const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
// Преобразуем mediaId в строку для сравнения // Преобразуем mediaId в строку для сравнения
@@ -47,33 +46,28 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
useEffect(() => { useEffect(() => {
const checkFavorite = async () => { const checkFavorite = async () => {
// Проверяем только если пользователь авторизован if (!token) return;
if (status !== 'authenticated' || !session?.user?.email) return;
try { try {
const response = await favoritesAPI.getFavorites(); const { data } = await favoritesAPI.checkFavorite(mediaIdString);
const favorites = response.data; setIsFavorite(!!data.isFavorite);
const isFav = favorites.some(
fav => fav.mediaId === mediaIdString && fav.mediaType === mediaType
);
setIsFavorite(isFav);
} catch (error) { } catch (error) {
console.error('Error checking favorite status:', error); console.error('Error checking favorite status:', error);
} }
}; };
checkFavorite(); checkFavorite();
}, [session?.user?.email, mediaIdString, mediaType, status]); }, [token, mediaIdString]);
const toggleFavorite = async () => { const toggleFavorite = async () => {
if (!session?.user?.email) { if (!token) {
toast.error('Для добавления в избранное необходимо авторизоваться'); toast.error('Для добавления в избранное необходимо авторизоваться');
return; return;
} }
try { try {
if (isFavorite) { if (isFavorite) {
await favoritesAPI.removeFavorite(mediaIdString, mediaType); await favoritesAPI.removeFavorite(mediaIdString);
toast.success('Удалено из избранного'); toast.success('Удалено из избранного');
setIsFavorite(false); setIsFavorite(false);
} else { } else {
@@ -81,7 +75,7 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
mediaId: mediaIdString, mediaId: mediaIdString,
mediaType, mediaType,
title, title,
posterPath: posterPath || undefined, posterPath: posterPath || '',
}); });
toast.success('Добавлено в избранное'); toast.success('Добавлено в избранное');
setIsFavorite(true); setIsFavorite(true);

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { moviesAPI } from '@/lib/api'; import { moviesAPI, api } from '@/lib/api';
const PlayerContainer = styled.div` const PlayerContainer = styled.div`
position: relative; position: relative;
@@ -91,13 +91,13 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
// containerRef removed using direct iframe integration // containerRef removed using direct iframe integration
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentPlayer, setCurrentPlayer] = useState(settings.defaultPlayer);
const [iframeSrc, setIframeSrc] = useState<string | null>(null); const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [imdbMissing, setImdbMissing] = useState(false); const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null);
useEffect(() => { useEffect(() => {
if (isInitialized) { if (isInitialized) {
setCurrentPlayer(settings.defaultPlayer); // setCurrentPlayer(settings.defaultPlayer);
} }
}, [settings.defaultPlayer, isInitialized]); }, [settings.defaultPlayer, isInitialized]);
@@ -112,7 +112,7 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
if (!data?.imdb_id) { if (!data?.imdb_id) {
throw new Error('IMDb ID не найден'); throw new Error('IMDb ID не найден');
} }
imdbId = data.imdb_id; setResolvedImdb(data.imdb_id);
} }
} catch (err) { } catch (err) {
console.error('Error fetching IMDb ID:', err); console.error('Error fetching IMDb ID:', err);
@@ -122,41 +122,47 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
} }
}; };
fetchImdbId(); if (!resolvedImdb) {
}, [id, imdbId]); fetchImdbId();
}
}, [id, resolvedImdb]);
useEffect(() => { useEffect(() => {
const loadPlayer = async () => { const loadPlayer = async () => {
if (!isInitialized || !resolvedImdb) return;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
let currentImdb = imdbId; const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex';
if (!currentImdb) { const queryParams = { imdb_id: resolvedImdb };
const { data } = await moviesAPI.getMovie(id);
const imdb = (data as any)?.imdb_id;
if (!imdb) {
setImdbMissing(true);
} else {
setImdbMissing(false);
currentImdb = imdb;
}
}
if (currentPlayer === 'alloha') { try {
// сначала попробуем по IMDb const response = await api.get(basePath, { params: queryParams });
let res = await fetch(`/api/alloha?imdb_id=${currentImdb}`); if (!response.data) {
if (!res.ok) { throw new Error('Empty response');
// fallback на TMDB id (тот же id, передаваемый в компонент)
res = await fetch(`/api/alloha?tmdb_id=${id}`);
} }
if (!res.ok) throw new Error('Видео не найдено');
const json = await res.json(); let src: string | null = null;
setIframeSrc(json.iframe); if (response.data.iframe) {
} else if (currentPlayer === 'lumex') { src = response.data.iframe;
setIframeSrc(`${process.env.NEXT_PUBLIC_LUMEX_URL}?imdb_id=${currentImdb}`); } else if (response.data.src) {
} else { src = response.data.src;
throw new Error('Выбран неподдерживаемый плеер'); } else if (response.data.url) {
src = response.data.url;
} else if (typeof response.data === 'string') {
const match = response.data.match(/<iframe[^>]*src="([^"]+)"/i);
if (match && match[1]) src = match[1];
}
if (!src) {
throw new Error('Invalid response format');
}
setIframeSrc(src);
} catch (err) {
console.error(err);
setError('Не удалось загрузить плеер. Попробуйте позже.');
} finally {
setLoading(false);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -167,7 +173,7 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
}; };
loadPlayer(); loadPlayer();
}, [id, imdbId, currentPlayer]); }, [id, resolvedImdb, isInitialized, settings.defaultPlayer]);
const handleRetry = () => { const handleRetry = () => {
setLoading(true); setLoading(true);
@@ -202,11 +208,7 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
Для возможности скачивания фильма выберите плеер Lumex в настройках Для возможности скачивания фильма выберите плеер Lumex в настройках
</DownloadMessage> </DownloadMessage>
)} )}
{imdbMissing && settings.defaultPlayer !== 'alloha' && (
<DownloadMessage>
Для просмотра данного фильма/сериала выберите плеер Alloha
</DownloadMessage>
)}
</> </>
); );
} }

View File

@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react'; import { useAuth } from '../hooks/useAuth';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import styled from 'styled-components'; import styled from 'styled-components';
import SearchModal from './SearchModal'; import SearchModal from './SearchModal';
@@ -226,8 +227,52 @@ const AuthButtons = styled.div`
export default function Navbar() { export default function Navbar() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const { data: session, status } = useSession(); const { logout } = useAuth();
const [token, setToken] = useState<string | null>(null);
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
const [mounted, setMounted] = useState(false);
// Читаем localStorage после монтирования
useEffect(() => {
const storedToken = localStorage.getItem('token');
setToken(storedToken);
if (storedToken) {
const lsName = localStorage.getItem('userName');
const lsEmail = localStorage.getItem('userEmail');
if (lsName) setUserName(lsName);
if (lsEmail) setUserEmail(lsEmail);
if (!lsName || !lsEmail) {
try {
const payload = JSON.parse(atob(storedToken.split('.')[1]));
const name = lsName || payload.name || payload.username || payload.userName || payload.sub || '';
const email = lsEmail || payload.email || '';
if (name) {
localStorage.setItem('userName', name);
setUserName(name);
}
if (email) {
localStorage.setItem('userEmail', email);
setUserEmail(email);
}
} catch {}
}
}
setMounted(true);
// слушаем события авторизации, чтобы обновлять ник без перезагрузки
const handleAuthChanged = () => {
const t = localStorage.getItem('token');
setToken(t);
setUserName(localStorage.getItem('userName') || '');
setUserEmail(localStorage.getItem('userEmail') || '');
};
window.addEventListener('auth-changed', handleAuthChanged);
return () => window.removeEventListener('auth-changed', handleAuthChanged);
}, []);
const pathname = usePathname(); const pathname = usePathname();
// Ждём, пока компонент смонтируется, чтобы избежать гидрации с разными ветками
const router = useRouter(); const router = useRouter();
// Скрываем навбар на определенных страницах // Скрываем навбар на определенных страницах
@@ -235,10 +280,7 @@ export default function Navbar() {
return null; return null;
} }
// Если сессия загружается, показываем плейсхолдер
if (status === 'loading') {
return null;
}
const handleNavigation = (href: string, onClick?: () => void) => { const handleNavigation = (href: string, onClick?: () => void) => {
if (onClick) { if (onClick) {
@@ -304,11 +346,9 @@ export default function Navbar() {
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<DesktopSidebar> <DesktopSidebar>
<LogoContainer> <LogoContainer>
<div onClick={() => router.push('/')} style={{ cursor: 'pointer' }}> <Logo href="/">
<Logo as="div">
Neo <span>Movies</span> Neo <span>Movies</span>
</Logo> </Logo>
</div>
</LogoContainer> </LogoContainer>
<MenuContainer> <MenuContainer>
@@ -329,19 +369,19 @@ export default function Navbar() {
))} ))}
</MenuContainer> </MenuContainer>
{session ? ( {token ? (
<UserProfile> <UserProfile>
<UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}> <UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}>
<UserAvatar> <UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar> </UserAvatar>
<UserInfo> <UserInfo>
<div>{session.user?.name}</div> <div>{userName}</div>
<div>{session.user?.email}</div> <div>{userEmail}</div>
</UserInfo> </UserInfo>
</UserButton> </UserButton>
</UserProfile> </UserProfile>
) : ( ) : mounted ? (
<AuthButtons> <AuthButtons>
<div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}> <div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}> <MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
@@ -349,7 +389,7 @@ export default function Navbar() {
</MobileMenuItem> </MobileMenuItem>
</div> </div>
</AuthButtons> </AuthButtons>
)} ): null}
</DesktopSidebar> </DesktopSidebar>
{/* Mobile Navigation */} {/* Mobile Navigation */}
@@ -366,15 +406,15 @@ export default function Navbar() {
{/* Mobile Menu */} {/* Mobile Menu */}
<MobileMenu $isOpen={isMobileMenuOpen}> <MobileMenu $isOpen={isMobileMenuOpen}>
{session ? ( {token ? (
<UserProfile> <UserProfile>
<UserButton onClick={() => signOut()}> <UserButton onClick={() => { logout(); setIsMobileMenuOpen(false); }}>
<UserAvatar> <UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar> </UserAvatar>
<UserInfo> <UserInfo>
<div>{session.user?.name}</div> <div>{userName}</div>
<div>{session.user?.email}</div> <div>{userEmail}</div>
</UserInfo> </UserInfo>
</UserButton> </UserButton>
</UserProfile> </UserProfile>
@@ -398,7 +438,7 @@ export default function Navbar() {
</div> </div>
))} ))}
{!session && ( {!token && (
<AuthButtons> <AuthButtons>
<div onClick={() => { <div onClick={() => {
router.push('/login'); router.push('/login');

81
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,81 @@
"use client";
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { authAPI } from '../lib/authApi';
import { api } from '../lib/api';
interface PendingRegistration {
email: string;
password: string;
name?: string;
}
export function useAuth() {
const router = useRouter();
const [isVerifying, setIsVerifying] = useState(false);
const [pending, setPending] = useState<PendingRegistration | null>(null);
const login = async (email: string, password: string) => {
const { data } = await authAPI.login(email, password);
if (data?.token) {
localStorage.setItem('token', data.token);
// Extract name/email either from API response or JWT payload
// Пытаемся достать имя/почту из JWT либо из ответа
let name: string | undefined = undefined;
let email: string | undefined = undefined;
try {
const payload = JSON.parse(atob(data.token.split('.')[1]));
name = payload.name || payload.username || payload.userName || payload.sub || undefined;
email = payload.email || undefined;
} catch {
// silent
}
// fallback к полям ответа
if (!name) name = data.user?.name || data.name || data.userName;
if (!email) email = data.user?.email || data.email;
if (name) localStorage.setItem('userName', name);
if (email) localStorage.setItem('userEmail', email);
// уведомляем другие компоненты о смене авторизации
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
router.push('/');
} else {
throw new Error(data?.error || 'Login failed');
}
};
const register = async (email: string, password: string, name: string) => {
await authAPI.register({ email, password, name });
await authAPI.resendCode(email);
setIsVerifying(true);
setPending({ email, password, name });
};
const verifyCode = async (code: string) => {
if (!pending) throw new Error('no pending');
await authAPI.verify(pending.email, code);
// auto login
await login(pending.email, pending.password);
setIsVerifying(false);
setPending(null);
};
const logout = () => {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth-changed'));
}
router.push('/login');
};
return { login, register, verifyCode, logout, isVerifying };
}

View File

@@ -13,6 +13,25 @@ export const api = axios.create({
} }
}); });
// Attach JWT token if present in localStorage
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}
// Update stored token on login response with { token }
api.interceptors.response.use((response) => {
if (response.config.url?.includes('/auth/login') && response.data?.token) {
if (typeof window !== 'undefined') {
localStorage.setItem('token', response.data.token);
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`;
}
}
return response;
});
export interface Category { export interface Category {
id: number; id: number;
name: string; name: string;
@@ -41,6 +60,7 @@ export interface Movie {
export interface MovieDetails extends Movie { export interface MovieDetails extends Movie {
genres: Genre[]; genres: Genre[];
runtime: number; runtime: number;
imdb_id?: string | null;
tagline: string; tagline: string;
budget: number; budget: number;
revenue: number; revenue: number;

16
src/lib/authApi.ts Normal file
View File

@@ -0,0 +1,16 @@
import { api } from './api';
export const authAPI = {
register(data: { email: string; password: string; name?: string }) {
return api.post('/auth/register', data);
},
resendCode(email: string) {
return api.post('/auth/resend-code', { email });
},
verify(email: string, code: string) {
return api.put('/auth/verify', { email, code });
},
login(email: string, password: string) {
return api.post('/auth/login', { email, password });
}
};

View File

@@ -1,30 +1,24 @@
import axios from 'axios'; import { api } from './api';
// Создаем экземпляр axios
const api = axios.create({
headers: {
'Content-Type': 'application/json'
}
});
export const favoritesAPI = { export const favoritesAPI = {
// Получить все избранные // Получить все избранные
getFavorites() { getFavorites() {
return api.get('/api/favorites'); return api.get('/favorites');
}, },
// Добавить в избранное // Добавить в избранное
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv'; title: string; posterPath?: string }) { addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) {
return api.post('/api/favorites', data); return api.post(`/favorites`, data);
}, },
// Удалить из избранного // Удалить из избранного
removeFavorite(mediaId: string, mediaType: 'movie' | 'tv') { removeFavorite(mediaId: string) {
return api.delete(`/api/favorites/${mediaId}?mediaType=${mediaType}`); return api.delete(`/favorites/${mediaId}`);
}, },
// Проверить есть ли в избранном // Проверить есть ли в избранном
checkFavorite(mediaId: string, mediaType: 'movie' | 'tv') { checkFavorite(mediaId: string) {
return api.get(`/api/favorites/check/${mediaId}?mediaType=${mediaType}`); return api.get(`/favorites/check/${mediaId}`);
} }
}; };

View File

@@ -1,53 +0,0 @@
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { withAuth } from 'next-auth/middleware';
import { NextRequestWithAuth } from 'next-auth/middleware';
export default withAuth(
async function middleware(request: NextRequestWithAuth) {
const token = await getToken({ req: request });
const isAuth = !!token;
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isAdminPage = request.nextUrl.pathname.startsWith('/admin');
const isAdminLoginPage = request.nextUrl.pathname === '/admin/login';
// Если это страница админ-панели
if (isAdminPage) {
// Если пользователь не авторизован
if (!isAuth) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
// Если пользователь не админ и пытается зайти на админ-страницы (кроме логина)
if (!token?.isAdmin && !isAdminLoginPage) {
return NextResponse.redirect(new URL('/', request.url));
}
// Если админ уже прошел верификацию и пытается зайти на страницу логина
if (token?.isAdmin && token?.adminVerified && isAdminLoginPage) {
return NextResponse.redirect(new URL('/admin', request.url));
}
// Если админ не прошел верификацию и пытается зайти на админ-страницы (кроме логина)
if (token?.isAdmin && !token?.adminVerified && !isAdminLoginPage) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
// Если авторизованный пользователь пытается зайти на страницу логина
if (isAuthPage && isAuth) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: () => true,
},
}
);
export const config = {
matcher: ['/login', '/admin/:path*'],
};

41
src/models/Favorite.ts Normal file
View File

@@ -0,0 +1,41 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IFavorite extends Document {
userId: mongoose.Schema.Types.ObjectId;
mediaId: string;
mediaType: 'movie' | 'tv';
title: string;
posterPath: string;
}
const FavoriteSchema: Schema = new Schema({
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
},
mediaId: {
type: String,
required: true,
},
mediaType: {
type: String,
enum: ['movie', 'tv'],
required: true,
},
title: {
type: String,
required: true,
},
posterPath: {
type: String,
required: true,
},
}, {
timestamps: true,
});
// Ensure a user can't favorite the same item multiple times
FavoriteSchema.index({ userId: 1, mediaId: 1 }, { unique: true });
export default mongoose.models.Favorite || mongoose.model<IFavorite>('Favorite', FavoriteSchema);

View File

@@ -1,6 +1,8 @@
import User from './User'; import User from './User';
import Movie from './Movie'; import Movie from './Movie';
import Favorite from './Favorite';
export { User, Movie }; export { default as Movie } from './Movie';
export type { IUser } from './User'; export { default as User } from './User';
export { default as Favorite } from './Favorite';
export type { Movie as MovieType } from './Movie'; export type { Movie as MovieType } from './Movie';