mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-27 17:38:50 +05:00
Authorization, favorites and players have been moved to the API server
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.next
|
||||
48
README.md
48
README.md
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('Не удалось отправить код');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
81
src/hooks/useAuth.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
16
src/lib/authApi.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
41
src/models/Favorite.ts
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user