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
- TypeScript
- Styled Components
- NextAuth.js
- JWT-based authentication (custom)
- **Backend:**
- Next.js
- MongoDB
- Mongoose
- Node.js + Express (neomovies-api)
- MongoDB (native driver)
- **Дополнительно:**
- ESLint
@@ -54,31 +53,9 @@ npm install
3. Создайте файл `.env` и добавьте следующие переменные:
```env
# База данных MongoDB
MONGODB_URI=your_mongodb_uri
# NextAuth конфигурация
NEXTAUTH_SECRET=your_nextauth_secret
NEXTAUTH_URL=http://localhost:3000
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email конфигурация (для подтверждения регистрации)
GMAIL_USER=your_gmail@gmail.com
GMAIL_APP_PASSWORD=your_app_specific_password
NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app
# JWT конфигурация
JWT_SECRET=your_jwt_secret
# Lumex Player URL
NEXT_PUBLIC_LUMEX_URL=your_lumex_player_url
#Alloha token
ALLOHA_TOKEN=your_token
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
```
@@ -93,7 +70,7 @@ npm start
```
Приложение будет доступно по адресу [http://localhost:3000](http://localhost:3000)
## API
## API (neomovies-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
1. Включите двухфакторную аутентификацию в аккаунте Google
2. Перейдите в настройки безопасности
3. Создайте пароль приложения
4. Используйте этот пароль в GMAIL_APP_PASSWORD
Backend `.env` пример смотрите в репозитории [neomovies-api](https://gitlab.com/foxixus/neomovies-api).
---
## Структура проекта
```

View File

@@ -10,13 +10,13 @@
},
"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",
"@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.13",
"@types/styled-components": "^5.1.34",
"@vercel/analytics": "^1.0.1",
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
@@ -27,7 +27,6 @@
"mongodb": "^6.12.0",
"mongoose": "^8.9.2",
"next": "15.1.2",
"next-auth": "^4.24.11",
"nodemailer": "^6.9.16",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,7 +1,8 @@
'use client';
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 styled from 'styled-components';
@@ -91,29 +92,27 @@ export default function AdminLoginClient() {
setIsLoading(true);
try {
const result = await signIn('credentials', {
const response = await api.post('/auth/login', {
email,
password,
isAdminLogin: 'true',
redirect: false,
});
if (result?.error) {
switch (result.error) {
case 'NOT_AN_ADMIN':
setError('У вас нет прав администратора');
break;
case 'EMAIL_NOT_VERIFIED':
setError('Пожалуйста, подтвердите свой email');
break;
default:
setError('Неверный email или пароль');
const { token, user } = response.data;
if (user?.role !== 'admin') {
setError('У вас нет прав администратора.');
setIsLoading(false);
return;
}
} else {
localStorage.setItem('token', token);
localStorage.setItem('userName', user.name);
localStorage.setItem('userEmail', user.email);
router.push('/admin');
}
} catch (error) {
setError('Произошла ошибка при входе');
} catch (err) {
const axiosError = err as AxiosError<{ message: string }>;
setError(axiosError.response?.data?.message || 'Неверный email или пароль');
} finally {
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 Link from 'next/link';
import Image from 'next/image';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { favoritesAPI } from '@/lib/favoritesApi';
import { getImageUrl } from '@/lib/neoApi';
@@ -87,14 +87,15 @@ interface Favorite {
}
export default function FavoritesPage() {
const { data: session } = useSession();
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchFavorites = async () => {
if (!session?.user) {
setLoading(false);
const token = localStorage.getItem('token');
if (!token) {
router.push('/login');
return;
}
@@ -102,14 +103,19 @@ export default function FavoritesPage() {
const response = await favoritesAPI.getFavorites();
setFavorites(response.data);
} 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 {
setLoading(false);
}
};
fetchFavorites();
}, [session?.user]);
}, [router]);
if (loading) {
return (
@@ -120,16 +126,7 @@ export default function FavoritesPage() {
);
}
if (!session?.user) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>
Для доступа к избранному необходимо авторизоваться
</EmptyState>
</Container>
);
}
if (favorites.length === 0) {
return (

View File

@@ -1,8 +1,8 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { signIn } from 'next-auth/react';
import { useAuth } from '../../hooks/useAuth';
import { useRouter } from 'next/navigation';
const Container = styled.div`
@@ -173,6 +173,14 @@ export default function LoginClient() {
const [name, setName] = useState('');
const [error, setError] = useState('');
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) => {
e.preventDefault();
@@ -180,38 +188,11 @@ export default function LoginClient() {
try {
if (isLogin) {
const result = await signIn('credentials', {
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('/');
await login(email, password);
} else {
const response = await fetch('/api/auth/register', {
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();
await register(email, password, name);
// Сохраняем пароль для автовхода после верификации
localStorage.setItem('password', password);
router.push(`/verify?email=${encodeURIComponent(email)}`);
}
} 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(
props: { params: { id: string }}
): Promise<Metadata> {
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
const { params } = await props;
// В Next.js 14, нужно сначала получить данные фильма,
// а затем использовать их для метаданных
try {
// Получаем id для использования в запросе
const movieId = props.params.id;
const movieId = params.id;
// Запрашиваем данные фильма
const { data: movie } = await moviesAPI.getMovie(movieId);

View File

@@ -1,10 +1,10 @@
'use client';
import { useSession } from 'next-auth/react';
import { useAuth } from '@/hooks/useAuth';
import styled from 'styled-components';
import GlassCard from '@/components/GlassCard';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
const Container = styled.div`
min-height: 100vh;
@@ -70,16 +70,28 @@ const SignOutButton = styled.button`
`;
export default function ProfilePage() {
const { data: session, status } = useSession();
const { logout } = useAuth();
const router = useRouter();
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (status === 'unauthenticated') {
const token = localStorage.getItem('token');
if (!token) {
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 (
<Container>
<Content>
@@ -91,7 +103,7 @@ export default function ProfilePage() {
);
}
if (!session) {
if (!userName) {
return null;
}
@@ -101,14 +113,12 @@ export default function ProfilePage() {
<GlassCard>
<ProfileHeader>
<Avatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</Avatar>
<Name>{session.user?.name}</Name>
<Email>{session.user?.email}</Email>
<Name>{userName}</Name>
<Email>{userEmail}</Email>
<SignOutButton onClick={handleSignOut}>Выйти</SignOutButton>
</ProfileHeader>
<SignOutButton onClick={() => router.push('/settings')}>
Настройки
</SignOutButton>
</GlassCard>
</Content>
</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 styled from 'styled-components';
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`
width: 100%;
@@ -40,7 +41,7 @@ const CodeInput = styled.input`
&:focus {
outline: none;
border-color: ${({ theme }) => theme.colors.primary};
border-color: #2196f3;
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
}
@@ -82,7 +83,7 @@ const VerifyButton = styled.button`
const ResendButton = styled.button`
background: none;
border: none;
color: ${({ theme }) => theme.colors.primary};
color: #2196f3;
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem;
@@ -110,6 +111,7 @@ export function VerificationClient({ email }: { email: string }) {
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const router = useRouter();
const { verifyCode, login } = useAuth();
const searchParams = useSearchParams();
useEffect(() => {
@@ -134,32 +136,7 @@ export function VerificationClient({ email }: { email: string }) {
setError('');
try {
const response = await fetch('/api/auth/verify', {
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('/');
await verifyCode(code);
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally {
@@ -169,18 +146,7 @@ export function VerificationClient({ email }: { email: string }) {
const handleResend = async () => {
try {
const response = await fetch('/api/auth/resend-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
throw new Error('Не удалось отправить код');
}
await authAPI.resendCode(email);
setCountdown(60);
} catch (err) {
setError('Не удалось отправить код');

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
import { useState } from 'react';
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 styled from 'styled-components';
import SearchModal from './SearchModal';
@@ -226,8 +227,52 @@ const AuthButtons = styled.div`
export default function Navbar() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = 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 router = useRouter();
// Скрываем навбар на определенных страницах
@@ -235,10 +280,7 @@ export default function Navbar() {
return null;
}
// Если сессия загружается, показываем плейсхолдер
if (status === 'loading') {
return null;
}
const handleNavigation = (href: string, onClick?: () => void) => {
if (onClick) {
@@ -304,11 +346,9 @@ export default function Navbar() {
{/* Desktop Sidebar */}
<DesktopSidebar>
<LogoContainer>
<div onClick={() => router.push('/')} style={{ cursor: 'pointer' }}>
<Logo as="div">
<Logo href="/">
Neo <span>Movies</span>
</Logo>
</div>
</LogoContainer>
<MenuContainer>
@@ -329,19 +369,19 @@ export default function Navbar() {
))}
</MenuContainer>
{session ? (
{token ? (
<UserProfile>
<UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}>
<UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{session.user?.name}</div>
<div>{session.user?.email}</div>
<div>{userName}</div>
<div>{userEmail}</div>
</UserInfo>
</UserButton>
</UserProfile>
) : (
) : mounted ? (
<AuthButtons>
<div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
@@ -349,7 +389,7 @@ export default function Navbar() {
</MobileMenuItem>
</div>
</AuthButtons>
)}
): null}
</DesktopSidebar>
{/* Mobile Navigation */}
@@ -366,15 +406,15 @@ export default function Navbar() {
{/* Mobile Menu */}
<MobileMenu $isOpen={isMobileMenuOpen}>
{session ? (
{token ? (
<UserProfile>
<UserButton onClick={() => signOut()}>
<UserButton onClick={() => { logout(); setIsMobileMenuOpen(false); }}>
<UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{session.user?.name}</div>
<div>{session.user?.email}</div>
<div>{userName}</div>
<div>{userEmail}</div>
</UserInfo>
</UserButton>
</UserProfile>
@@ -398,7 +438,7 @@ export default function Navbar() {
</div>
))}
{!session && (
{!token && (
<AuthButtons>
<div onClick={() => {
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 {
id: number;
name: string;
@@ -41,6 +60,7 @@ export interface Movie {
export interface MovieDetails extends Movie {
genres: Genre[];
runtime: number;
imdb_id?: string | null;
tagline: string;
budget: 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 = {
// Получить все избранные
getFavorites() {
return api.get('/api/favorites');
return api.get('/favorites');
},
// Добавить в избранное
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv'; title: string; posterPath?: string }) {
return api.post('/api/favorites', data);
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv', title: string, posterPath: string }) {
return api.post(`/favorites`, data);
},
// Удалить из избранного
removeFavorite(mediaId: string, mediaType: 'movie' | 'tv') {
return api.delete(`/api/favorites/${mediaId}?mediaType=${mediaType}`);
removeFavorite(mediaId: string) {
return api.delete(`/favorites/${mediaId}`);
},
// Проверить есть ли в избранном
checkFavorite(mediaId: string, mediaType: 'movie' | 'tv') {
return api.get(`/api/favorites/check/${mediaId}?mediaType=${mediaType}`);
checkFavorite(mediaId: string) {
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 Movie from './Movie';
import Favorite from './Favorite';
export { User, Movie };
export type { IUser } from './User';
export { default as Movie } from './Movie';
export { default as User } from './User';
export { default as Favorite } from './Favorite';
export type { Movie as MovieType } from './Movie';