diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fd5f43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +.env.local +.next diff --git a/README.md b/README.md index a904f1b..28129d8 100644 --- a/README.md +++ b/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). + +--- + ## Структура проекта ``` diff --git a/package.json b/package.json index 9ea43cd..3f58a14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/admin/login/AdminLoginClient.tsx b/src/app/admin/login/AdminLoginClient.tsx index 5660e2d..b8b7dee 100644 --- a/src/app/admin/login/AdminLoginClient.tsx +++ b/src/app/admin/login/AdminLoginClient.tsx @@ -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 или пароль'); - } - } else { - router.push('/admin'); + const { token, user } = response.data; + + if (user?.role !== 'admin') { + setError('У вас нет прав администратора.'); + setIsLoading(false); + return; } - } catch (error) { - setError('Произошла ошибка при входе'); + + localStorage.setItem('token', token); + localStorage.setItem('userName', user.name); + localStorage.setItem('userEmail', user.email); + + router.push('/admin'); + } catch (err) { + const axiosError = err as AxiosError<{ message: string }>; + setError(axiosError.response?.data?.message || 'Неверный email или пароль'); } finally { setIsLoading(false); } diff --git a/src/app/api/admin/create/route.ts b/src/app/api/admin/create/route.ts deleted file mode 100644 index 1467e85..0000000 --- a/src/app/api/admin/create/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/movies/route.ts b/src/app/api/admin/movies/route.ts deleted file mode 100644 index 4f91055..0000000 --- a/src/app/api/admin/movies/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/movies/toggle-visibility/route.ts b/src/app/api/admin/movies/toggle-visibility/route.ts deleted file mode 100644 index a5e4375..0000000 --- a/src/app/api/admin/movies/toggle-visibility/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/send-verification/route.ts b/src/app/api/admin/send-verification/route.ts deleted file mode 100644 index f6cd8bd..0000000 --- a/src/app/api/admin/send-verification/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/toggle-admin/route.ts b/src/app/api/admin/toggle-admin/route.ts deleted file mode 100644 index 810064b..0000000 --- a/src/app/api/admin/toggle-admin/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/users/toggle-admin/route.ts b/src/app/api/admin/users/toggle-admin/route.ts deleted file mode 100644 index 810064b..0000000 --- a/src/app/api/admin/users/toggle-admin/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/admin/verify-code/route.ts b/src/app/api/admin/verify-code/route.ts deleted file mode 100644 index 78a4687..0000000 --- a/src/app/api/admin/verify-code/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/alloha/route.ts b/src/app/api/alloha/route.ts deleted file mode 100644 index 8ee5898..0000000 --- a/src/app/api/alloha/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 3b5f4a7..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -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 }; diff --git a/src/app/api/auth/check-verification/route.ts b/src/app/api/auth/check-verification/route.ts deleted file mode 100644 index 9af30b5..0000000 --- a/src/app/api/auth/check-verification/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts deleted file mode 100644 index be6b8d4..0000000 --- a/src/app/api/auth/register/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/auth/resend-code/route.ts b/src/app/api/auth/resend-code/route.ts deleted file mode 100644 index 4892311..0000000 --- a/src/app/api/auth/resend-code/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts deleted file mode 100644 index da05528..0000000 --- a/src/app/api/auth/verify/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/favorites/[mediaId]/route.ts b/src/app/api/favorites/[mediaId]/route.ts deleted file mode 100644 index 8de955c..0000000 --- a/src/app/api/favorites/[mediaId]/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/favorites/check/[mediaId]/route.ts b/src/app/api/favorites/check/[mediaId]/route.ts deleted file mode 100644 index 1db2ffb..0000000 --- a/src/app/api/favorites/check/[mediaId]/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts deleted file mode 100644 index a78318b..0000000 --- a/src/app/api/favorites/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/mobile/auth/login/route.ts b/src/app/api/mobile/auth/login/route.ts deleted file mode 100644 index 47a098c..0000000 --- a/src/app/api/mobile/auth/login/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/mobile/auth/register/route.ts b/src/app/api/mobile/auth/register/route.ts deleted file mode 100644 index e8c9081..0000000 --- a/src/app/api/mobile/auth/register/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/mobile/auth/resend-code/route.ts b/src/app/api/mobile/auth/resend-code/route.ts deleted file mode 100644 index 3bda650..0000000 --- a/src/app/api/mobile/auth/resend-code/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/mobile/auth/verify/route.ts b/src/app/api/mobile/auth/verify/route.ts deleted file mode 100644 index 04b4549..0000000 --- a/src/app/api/mobile/auth/verify/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/favorites/page.tsx b/src/app/favorites/page.tsx index b6ce926..b4b6de9 100644 --- a/src/app/favorites/page.tsx +++ b/src/app/favorites/page.tsx @@ -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([]); 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 ( - - Избранное - - Для доступа к избранному необходимо авторизоваться - - - ); - } + if (favorites.length === 0) { return ( diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index 21ee954..ff875b6 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -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) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e69de29..7a236b4 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 ( + + + + + + + + + + Neo Movies + + + + + + + + ); +} + +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; +`; \ No newline at end of file diff --git a/src/app/movie/[id]/page.tsx b/src/app/movie/[id]/page.tsx index 86ac563..3cec36b 100644 --- a/src/app/movie/[id]/page.tsx +++ b/src/app/movie/[id]/page.tsx @@ -9,14 +9,13 @@ interface PageProps { } // Генерация метаданных для страницы -export async function generateMetadata( - props: { params: { id: string }} -): Promise { +export async function generateMetadata(props: Promise): Promise { + 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); diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 4bb43f8..c14b9ac 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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(null); + const [userEmail, setUserEmail] = useState(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 ( @@ -91,7 +103,7 @@ export default function ProfilePage() { ); } - if (!session) { + if (!userName) { return null; } @@ -101,14 +113,12 @@ export default function ProfilePage() { - {session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} + {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} - {session.user?.name} - {session.user?.email} + {userName} + {userEmail} + Выйти - router.push('/settings')}> - Настройки - diff --git a/src/app/providers.tsx b/src/app/providers.tsx deleted file mode 100644 index f900ca9..0000000 --- a/src/app/providers.tsx +++ /dev/null @@ -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 ( - - - {children} - - - ); -} diff --git a/src/app/verify/VerificationClient.tsx b/src/app/verify/VerificationClient.tsx index 35ce0a4..d19560d 100644 --- a/src/app/verify/VerificationClient.tsx +++ b/src/app/verify/VerificationClient.tsx @@ -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('Не удалось отправить код'); diff --git a/src/components/ClientLayout.tsx b/src/components/ClientLayout.tsx index 3759510..269aace 100644 --- a/src/components/ClientLayout.tsx +++ b/src/components/ClientLayout.tsx @@ -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,14 +16,12 @@ const theme = { export function ClientLayout({ children }: { children: React.ReactNode }) { return ( - - + {children} - ); } diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx index 4372d9f..80d6ff0 100644 --- a/src/components/FavoriteButton.tsx +++ b/src/components/FavoriteButton.tsx @@ -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); diff --git a/src/components/MoviePlayer.tsx b/src/components/MoviePlayer.tsx index 49fb3ae..d2aab9f 100644 --- a/src/components/MoviePlayer.tsx +++ b/src/components/MoviePlayer.tsx @@ -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(null); - const [currentPlayer, setCurrentPlayer] = useState(settings.defaultPlayer); + const [iframeSrc, setIframeSrc] = useState(null); - const [imdbMissing, setImdbMissing] = useState(false); + const [resolvedImdb, setResolvedImdb] = useState(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 } }; - fetchImdbId(); - }, [id, imdbId]); + if (!resolvedImdb) { + fetchImdbId(); + } + }, [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 }; - 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}`); + try { + const response = await api.get(basePath, { params: queryParams }); + if (!response.data) { + throw new Error('Empty response'); } - 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('Выбран неподдерживаемый плеер'); + + 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(/]*src="([^"]+)"/i); + if (match && match[1]) src = match[1]; + } + if (!src) { + throw new Error('Invalid response format'); + } + setIframeSrc(src); + } catch (err) { + console.error(err); + setError('Не удалось загрузить плеер. Попробуйте позже.'); + } finally { + setLoading(false); } } catch (err) { 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 в настройках )} - {imdbMissing && settings.defaultPlayer !== 'alloha' && ( - - Для просмотра данного фильма/сериала выберите плеер Alloha - - )} + ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 43719b9..17ad5e5 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -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(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 */} -
router.push('/')} style={{ cursor: 'pointer' }}> - + Neo Movies -
@@ -329,19 +369,19 @@ export default function Navbar() { ))} - {session ? ( + {token ? ( router.push('/profile')} style={{ cursor: 'pointer' }}> - {session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} + {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} -
{session.user?.name}
-
{session.user?.email}
+
{userName}
+
{userEmail}
- ) : ( + ) : mounted ? (
router.push('/login')} style={{ cursor: 'pointer' }}> @@ -349,7 +389,7 @@ export default function Navbar() {
- )} + ): null}
{/* Mobile Navigation */} @@ -366,15 +406,15 @@ export default function Navbar() { {/* Mobile Menu */} - {session ? ( + {token ? ( - signOut()}> + { logout(); setIsMobileMenuOpen(false); }}> - {session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} + {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''} -
{session.user?.name}
-
{session.user?.email}
+
{userName}
+
{userEmail}
@@ -398,7 +438,7 @@ export default function Navbar() { ))} - {!session && ( + {!token && (
{ router.push('/login'); diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..3ab6109 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -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(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 }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 8d812db..96e6530 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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; diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts new file mode 100644 index 0000000..a8ec941 --- /dev/null +++ b/src/lib/authApi.ts @@ -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 }); + } +}; diff --git a/src/lib/favoritesApi.ts b/src/lib/favoritesApi.ts index a82a4b2..369cecc 100644 --- a/src/lib/favoritesApi.ts +++ b/src/lib/favoritesApi.ts @@ -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}`); } }; diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 2031ac6..0000000 --- a/src/middleware.ts +++ /dev/null @@ -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*'], -}; diff --git a/src/models/Favorite.ts b/src/models/Favorite.ts new file mode 100644 index 0000000..591f2d2 --- /dev/null +++ b/src/models/Favorite.ts @@ -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('Favorite', FavoriteSchema); diff --git a/src/models/index.ts b/src/models/index.ts index dc4798a..46c9279 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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';