Update 103 files

- /public/file.svg
- /public/globe.svg
- /public/next.svg
- /public/vercel.svg
- /public/window.svg
- /public/google.svg
- /public/logo.png
- /src/eslint.config.mjs
- /src/api.ts
- /src/middleware.ts
- /src/app/favicon.ico
- /src/app/globals.css
- /src/app/layout.tsx
- /src/app/page.tsx
- /src/app/providers.tsx
- /src/app/not-found.tsx
- /src/app/error.tsx
- /src/app/metadata.ts
- /src/app/styles.tsx
- /src/app/api/auth/[...nextauth]/route.ts
- /src/app/api/auth/register/route.ts
- /src/app/api/auth/verify/route.ts
- /src/app/api/auth/check-verification/route.ts
- /src/app/api/auth/resend-code/route.ts
- /src/app/api/movies/search/route.ts
- /src/app/api/movies/sync/route.ts
- /src/app/api/admin/send-verification/route.ts
- /src/app/api/admin/verify-code/route.ts
- /src/app/api/admin/movies/route.ts
- /src/app/api/admin/movies/toggle-visibility/route.ts
- /src/app/api/admin/create/route.ts
- /src/app/api/admin/users/toggle-admin/route.ts
- /src/app/api/admin/toggle-admin/route.ts
- /src/app/login/page.tsx
- /src/app/login/LoginClient.tsx
- /src/app/verify/page.tsx
- /src/app/verify/VerificationClient.tsx
- /src/app/profile/page.tsx
- /src/app/movie/[id]/page.tsx
- /src/app/movie/[id]/MoviePage.tsx
- /src/app/movie/[id]/MovieContent.tsx
- /src/app/settings/page.tsx
- /src/app/tv/[id]/page.tsx
- /src/app/tv/[id]/TVShowPage.tsx
- /src/app/tv/[id]/TVShowContent.tsx
- /src/app/admin/login/page.tsx
- /src/app/admin/login/AdminLoginClient.tsx
- /src/lib/db.ts
- /src/lib/jwt.ts
- /src/lib/registry.tsx
- /src/lib/api.ts
- /src/lib/mongodb.ts
- /src/lib/mailer.ts
- /src/lib/auth.ts
- /src/lib/utils.ts
- /src/lib/email.ts
- /src/lib/movieSync.ts
- /src/models/User.ts
- /src/models/index.ts
- /src/models/Movie.ts
- /src/types/auth.ts
- /src/types/movie.ts
- /src/components/MovieCard.tsx
- /src/components/Notification.tsx
- /src/components/Pagination.tsx
- /src/components/GoogleIcon.tsx
- /src/components/StyleProvider.tsx
- /src/components/Providers.tsx
- /src/components/VerificationCodeInput.tsx
- /src/components/GlassCard.tsx
- /src/components/AppLayout.tsx
- /src/components/SearchModal.tsx
- /src/components/DarkReaderFix.tsx
- /src/components/ClientLayout.tsx
- /src/components/MenuItem.tsx
- /src/components/MoviePlayer.tsx
- /src/components/PageLayout.tsx
- /src/components/SettingsContent.tsx
- /src/components/Navbar.tsx
- /src/components/LayoutContent.tsx
- /src/components/SearchResults.tsx
- /src/components/Icons/Icons.tsx
- /src/components/Icons/HeartIcon.tsx
- /src/components/Icons/PlayIcon.tsx
- /src/components/admin/MovieSearch.tsx
- /src/hooks/useUser.ts
- /src/hooks/useMovies.ts
- /src/hooks/useSettings.ts
- /src/hooks/useSearch.ts
- /src/styles/GlobalStyles.ts
- /src/styles/GlobalStyles.tsx
- /src/providers/AuthProvider.tsx
- /src/data/movies.ts
- /types/next-auth.d.ts
- /middleware.ts
- /next.config.js
- /next-env.d.ts
- /package.json
- /postcss.config.mjs
- /README.md
- /tailwind.config.ts
- /tsconfig.json
- /package-lock.json
This commit is contained in:
2024-12-23 18:42:18 +00:00
parent 4ccfd581ad
commit ebf23e4246
103 changed files with 14273 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
background: #1a1a1a;
`;
const Form = styled.form`
width: 100%;
max-width: 400px;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
const Title = styled.h1`
color: white;
text-align: center;
margin-bottom: 2rem;
font-size: 1.5rem;
`;
const Input = styled.input`
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 1rem;
&:focus {
outline: none;
border-color: #3b82f6;
}
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
`;
const Button = styled.button`
width: 100%;
padding: 0.75rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #2563eb;
}
&:disabled {
background: #64748b;
cursor: not-allowed;
}
`;
const ErrorMessage = styled.div`
color: #ef4444;
margin-bottom: 1rem;
text-align: center;
`;
export default function AdminLoginClient() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const result = await signIn('credentials', {
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');
}
} catch (error) {
setError('Произошла ошибка при входе');
} finally {
setIsLoading(false);
}
};
return (
<Container>
<Form onSubmit={handleSubmit}>
<Title>Вход в админ-панель</Title>
{error && <ErrorMessage>{error}</ErrorMessage>}
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Вход...' : 'Войти'}
</Button>
</Form>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import AdminLoginClient from './AdminLoginClient';
export default function AdminLoginPage() {
return <AdminLoginClient />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongodb';
export async function POST(req: Request) {
try {
const { email } = await req.json();
const { db } = await connectToDatabase();
const user = await db.collection('users').findOne({ email });
if (!user) {
return NextResponse.json({ error: 'Пользователь не найден' }, { status: 404 });
}
return NextResponse.json({ verified: user.verified ?? false });
} catch (error) {
console.error('Error checking verification status:', error);
return NextResponse.json(
{ error: 'Внутренняя ошибка сервера' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { hash } from 'bcryptjs';
import { connectToDatabase } from '@/lib/mongodb';
import { sendVerificationEmail } from '@/lib/mailer';
function generateVerificationCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export async function POST(req: Request) {
try {
const { email, password, name } = await req.json();
const { db } = await connectToDatabase();
// Проверяем, существует ли пользователь
const existingUser = await db.collection('users').findOne({ email });
if (existingUser) {
return NextResponse.json(
{ error: 'Email уже зарегистрирован' },
{ status: 400 }
);
}
// Хешируем пароль
const hashedPassword = await hash(password, 12);
// Генерируем код подтверждения
const verificationCode = generateVerificationCode();
const verificationExpires = new Date(Date.now() + 10 * 60 * 1000); // 10 минут
// Создаем пользователя
await db.collection('users').insertOne({
email,
password: hashedPassword,
name,
verified: false,
verificationCode,
verificationExpires,
isAdmin: false, // Явно указываем, что новый пользователь не админ
createdAt: new Date(),
});
// Отправляем код подтверждения
await sendVerificationEmail(email, verificationCode);
return NextResponse.json({
success: true,
email,
message: 'Пользователь успешно зарегистрирован',
});
} catch {
return NextResponse.json(
{ message: 'Ошибка при регистрации' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongodb';
import { sendVerificationEmail } from '@/lib/mailer';
function generateVerificationCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export async function POST(req: Request) {
try {
const { email } = await req.json();
const { db } = await connectToDatabase();
const user = await db.collection('users').findOne({ email });
if (!user) {
return NextResponse.json(
{ error: 'Пользователь не найден' },
{ status: 404 }
);
}
// Генерируем новый код
const verificationCode = generateVerificationCode();
const verificationExpires = new Date(Date.now() + 10 * 60 * 1000); // 10 минут
// Обновляем код в базе
await db.collection('users').updateOne(
{ email },
{
$set: {
verificationCode,
verificationExpires,
},
}
);
// Отправляем новый код
await sendVerificationEmail(email, verificationCode);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ message: 'Ошибка при отправке кода' },
{ status: 500 }
);
}
}

View File

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

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { searchAPI } from '@/lib/api';
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_API_URL = 'https://api.themoviedb.org/3';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('query');
if (!query) {
return NextResponse.json(
{ error: 'Query parameter is required' },
{ status: 400 }
);
}
try {
const { data } = await searchAPI.multiSearch(query);
return NextResponse.json(data);
} catch (error) {
console.error('Error searching:', error);
return NextResponse.json(
{ error: 'Failed to search' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { syncMovies } from '@/lib/movieSync';
export async function POST() {
try {
const movies = await syncMovies();
return NextResponse.json({ success: true, movies });
} catch (error) {
console.error('Error syncing movies:', error);
return NextResponse.json(
{ error: 'Failed to sync movies' },
{ status: 500 }
);
}
}

42
src/app/error.tsx Normal file
View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import styled from 'styled-components';
const Container = styled.div`
/* Add styles here */
`;
const Title = styled.h1`
/* Add styles here */
`;
const Description = styled.p`
/* Add styles here */
`;
const Button = styled.button`
/* Add styles here */
`;
export default function Error({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<Container>
<Title>Что-то пошло не так!</Title>
<Description>
Произошла ошибка при загрузке страницы. Попробуйте обновить страницу.
</Description>
<Button onClick={() => window.location.reload()}>
Обновить страницу
</Button>
</Container>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

51
src/app/globals.css Normal file
View File

@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 237, 237, 237;
--background-start-rgb: 14, 14, 14;
--background-end-rgb: 14, 14, 14;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
min-height: 100vh;
font-family: 'Inter', Arial, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Стили для Dark Reader */
[data-darkreader-mode] #__next,
[data-darkreader-mode] body,
[data-darkreader-mode] html {
background: rgb(14, 14, 14) !important;
}
/* Скрываем индикаторы Next.js */
#nextjs-portal {
display: none;
}
[data-nextjs-toast-wrapper] {
display: none !important;
}

28
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Inter } from 'next/font/google';
import './globals.css';
import { ClientLayout } from '@/components/ClientLayout';
import type { Metadata } from 'next';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = {
title: 'Neo Movies',
description: 'Смотрите фильмы онлайн',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<head>
<meta name="darkreader-lock" />
</head>
<body className={inter.className} suppressHydrationWarning>
<ClientLayout>{children}</ClientLayout>
</body>
</html>
);
}

View File

@@ -0,0 +1,317 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
const Container = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
`;
const Title = styled.h2`
font-size: 2rem;
font-weight: 700;
color: #fff;
text-align: center;
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-bottom: 2rem;
font-size: 1rem;
`;
const InputGroup = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
`;
const Label = styled.label`
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
`;
const Input = styled.input`
width: 100%;
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
transition: all 0.2s;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
&:focus {
outline: none;
border-color: #2196f3;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
}
`;
const Button = styled.button`
width: 100%;
background: linear-gradient(to right, #2196f3, #1e88e5);
color: white;
padding: 1rem;
border-radius: 12px;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 1rem;
&:hover {
background: linear-gradient(to right, #1e88e5, #1976d2);
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
}
&:active {
transform: translateY(0);
}
`;
const Divider = styled.div`
display: flex;
align-items: center;
text-align: center;
margin: 2rem 0;
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
`;
const DividerText = styled.span`
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
padding: 0 1rem;
`;
const GoogleButton = styled(Button)`
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
`;
const ToggleText = styled.p`
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-top: 2rem;
button {
color: #2196f3;
background: none;
border: none;
padding: 0;
margin-left: 0.5rem;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
`;
const ErrorMessage = styled.div`
color: #ff5252;
font-size: 0.875rem;
text-align: center;
padding: 0.75rem;
background: rgba(255, 82, 82, 0.1);
border-radius: 8px;
margin-top: 1rem;
`;
export default function LoginClient() {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
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('/');
} 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();
// Сохраняем пароль для автовхода после верификации
localStorage.setItem('password', password);
router.push(`/verify?email=${encodeURIComponent(email)}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка');
}
};
const handleGoogleSignIn = () => {
signIn('google', { callbackUrl: '/' });
};
return (
<Container>
<div>
<Title>{isLogin ? 'С возвращением!' : 'Создать аккаунт'}</Title>
<Subtitle>
{isLogin
? 'Войдите в свой аккаунт для доступа к фильмам'
: 'Зарегистрируйтесь для доступа ко всем возможностям'}
</Subtitle>
</div>
<Form onSubmit={handleSubmit}>
{!isLogin && (
<InputGroup>
<Label htmlFor="name">Имя</Label>
<Input
id="name"
type="text"
placeholder="Введите ваше имя"
value={name}
onChange={(e) => setName(e.target.value)}
required={!isLogin}
/>
</InputGroup>
)}
<InputGroup>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Введите ваш email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</InputGroup>
<InputGroup>
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
placeholder="Введите ваш пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</InputGroup>
{error && <ErrorMessage>{error}</ErrorMessage>}
<Button type="submit">
{isLogin ? 'Войти' : 'Зарегистрироваться'}
</Button>
</Form>
<Divider>
<DividerText>или</DividerText>
</Divider>
<GoogleButton type="button" onClick={handleGoogleSignIn}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"
fill="#4285f4"
/>
<path
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
fill="#34a853"
/>
<path
d="M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.707V4.961H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.046l3.007-2.339z"
fill="#fbbc05"
/>
<path
d="M9 3.582c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.961L3.964 7.3C4.672 5.173 6.656 3.582 9 3.582z"
fill="#ea4335"
/>
</svg>
Продолжить с Google
</GoogleButton>
<ToggleText>
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}
<button type="button" onClick={() => setIsLogin(!isLogin)}>
{isLogin ? 'Зарегистрироваться' : 'Войти'}
</button>
</ToggleText>
</Container>
);
}

129
src/app/login/page.tsx Normal file
View File

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

6
src/app/metadata.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Neo Movies',
description: 'Смотрите фильмы онлайн',
};

View File

@@ -0,0 +1,207 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import styled from 'styled-components';
import { moviesAPI } from '@/lib/api';
import type { MovieDetails } from '@/lib/api';
import { useSettings } from '@/hooks/useSettings';
import MoviePlayer from '@/components/MoviePlayer';
declare global {
interface Window {
kbox: any;
}
}
const Container = styled.div`
width: 100%;
min-height: 100vh;
padding: 0 24px;
`;
const Content = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
`;
const MovieInfo = styled.div`
display: flex;
gap: 30px;
margin-bottom: 1rem;
@media (max-width: 768px) {
flex-direction: column;
}
`;
const PosterContainer = styled.div`
flex-shrink: 0;
`;
const Poster = styled.img`
width: 300px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
width: 100%;
max-width: 300px;
margin: 0 auto;
}
`;
const Details = styled.div`
flex: 1;
`;
const Title = styled.h1`
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: white;
`;
const Info = styled.div`
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
`;
const InfoItem = styled.span`
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
`;
const GenreList = styled.div`
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
`;
const Genre = styled.span`
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
`;
const Tagline = styled.div`
font-style: italic;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 1rem;
`;
const Overview = styled.p`
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
`;
const PlayerSection = styled.div`
margin-top: 2rem;
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
color: rgba(255, 255, 255, 0.7);
`;
const ErrorContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
color: #ff4444;
`;
import { useParams } from 'next/navigation';
export default function MovieContent() {
const { id: movieId } = useParams();
const { settings } = useSettings();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [movie, setMovie] = useState<MovieDetails | null>(null);
const [imdbId, setImdbId] = useState<string | null>(null);
useEffect(() => {
const fetchMovie = async () => {
if (!movieId) return;
try {
setLoading(true);
const response = await moviesAPI.getMovie(movieId);
setMovie(response.data);
const newImdbId = await moviesAPI.getImdbId(movieId);
if (!newImdbId) {
setError('IMDb ID не найден');
return;
}
setImdbId(newImdbId);
setError(null);
} catch (err) {
console.error('Error fetching movie:', err);
setError('Ошибка при загрузке фильма');
} finally {
setLoading(false);
}
};
fetchMovie();
}, [movieId]);
if (loading) return <LoadingContainer>Загрузка...</LoadingContainer>;
if (error) return <ErrorContainer>{error}</ErrorContainer>;
if (!movie || !imdbId) return null;
return (
<Container>
<Content>
<MovieInfo>
<PosterContainer>
<Poster
src={movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '/placeholder.jpg'}
alt={movie.title}
/>
</PosterContainer>
<Details>
<Title>{movie.title}</Title>
<Info>
<InfoItem>Рейтинг: {movie.vote_average.toFixed(1)}</InfoItem>
<InfoItem>Длительность: {movie.runtime} мин.</InfoItem>
<InfoItem>Дата выхода: {new Date(movie.release_date).toLocaleDateString('ru-RU')}</InfoItem>
</Info>
<GenreList>
{movie.genres.map(genre => (
<Genre key={genre.id}>{genre.name}</Genre>
))}
</GenreList>
{movie.tagline && <Tagline>{movie.tagline}</Tagline>}
<Overview>{movie.overview}</Overview>
</Details>
</MovieInfo>
<PlayerSection>
<Suspense fallback={<LoadingContainer>Загрузка плеера...</LoadingContainer>}>
<MoviePlayer
id={movie.id.toString()}
title={movie.title}
poster={movie.backdrop_path ? `https://image.tmdb.org/t/p/w1280${movie.backdrop_path}` : undefined}
imdbId={imdbId}
/>
</Suspense>
</PlayerSection>
</Content>
</Container>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import styled from 'styled-components';
import PageLayout from '@/components/PageLayout';
import MovieContent from './MovieContent';
import type { MovieDetails } from '@/lib/api';
const Container = styled.div`
width: 100%;
min-height: 100vh;
padding: 0 24px;
`;
interface MoviePageProps {
movieId: string;
movie: MovieDetails | null;
}
export default function MoviePage({ movieId, movie }: MoviePageProps) {
if (!movie) {
return (
<PageLayout>
<Container>
<div>Фильм не найден</div>
</Container>
</PageLayout>
);
}
return (
<PageLayout>
<Container>
<MovieContent movieId={movieId} initialMovie={movie} />
</Container>
</PageLayout>
);
}

View File

@@ -0,0 +1,23 @@
import MoviePage from './MoviePage';
import { moviesAPI } from '@/lib/api';
interface PageProps {
params: {
id: string;
};
}
async function getData(id: string) {
try {
const response = await moviesAPI.getMovie(id);
return { id, movie: response.data };
} catch (error) {
console.error('Error fetching movie:', error);
return { id, movie: null };
}
}
export default async function Page({ params }: PageProps) {
const data = await getData(params.id);
return <MoviePage movieId={data.id} movie={data.movie} />;
}

178
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import Link from 'next/link';
import styled from 'styled-components';
import { useEffect, useState } from 'react';
const Container = styled.div`
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #0a0a0a;
overflow: hidden;
z-index: 9999;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 1200px;
padding: 2rem;
position: relative;
z-index: 1;
`;
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;
text-align: center;
`;
const ErrorCode = styled.h1`
font-size: 120px;
font-weight: 700;
color: #2196f3;
margin: 0;
line-height: 1;
letter-spacing: 4px;
text-shadow: 0 4px 32px rgba(33, 150, 243, 0.3);
`;
const Title = styled.h2`
font-size: 24px;
color: #FFFFFF;
margin: 20px 0;
font-weight: 600;
`;
const Description = styled.p`
color: rgba(255, 255, 255, 0.7);
margin-bottom: 30px;
font-size: 16px;
line-height: 1.5;
`;
const HomeButton = styled(Link)`
display: inline-block;
padding: 12px 24px;
background: #2196f3;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s;
&:hover {
background: #1976d2;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.3);
}
`;
const GlowingBackground = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
opacity: 0;
transition: opacity 0.5s ease-in-out;
&.visible {
opacity: 1;
}
`;
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;
`;
export default function NotFound() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<Container>
{isClient && (
<GlowingBackground className={isClient ? 'visible' : ''}>
<Glow1 />
<Glow2 />
<Glow3 />
</GlowingBackground>
)}
<Content>
<GlassCard>
<ErrorCode>404</ErrorCode>
<Title>Упс... Страница не найдена</Title>
<Description>
К сожалению, запрашиваемая страница не найдена.
<br />
Возможно, она была удалена или перемещена.
</Description>
<HomeButton href="/">Вернуться на главную</HomeButton>
</GlassCard>
</Content>
</Container>
);
}

193
src/app/page.tsx Normal file
View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { HeartIcon } from '@/components/Icons/HeartIcon';
import MovieCard from '@/components/MovieCard';
import { useMovies } from '@/hooks/useMovies';
import Pagination from '@/components/Pagination';
const Container = styled.div`
min-height: 100vh;
width: 100%;
padding: 24px;
padding-top: 84px;
@media (min-width: 769px) {
padding-left: 264px;
}
`;
const FeaturedMovie = styled.div`
position: relative;
width: 100%;
height: 600px;
background-size: cover;
background-position: center;
margin-bottom: 2rem;
border-radius: 24px;
overflow: hidden;
`;
const Overlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0.2) 100%);
display: flex;
align-items: center;
padding: 2rem;
`;
const FeaturedContent = styled.div`
max-width: 600px;
color: white;
`;
const GenreTags = styled.div`
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
`;
const GenreTag = styled.span`
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
`;
const Title = styled.h1`
font-size: 3rem;
font-weight: bold;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
`;
const Description = styled.p`
font-size: 1.125rem;
margin-bottom: 2rem;
opacity: 0.9;
line-height: 1.6;
`;
const ButtonGroup = styled.div`
display: flex;
gap: 1rem;
`;
const WatchButton = styled.div`
background: ${props => props.theme.colors.primary};
color: white;
padding: 0.75rem 2rem;
border: none;
border-radius: 9999px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: #2563eb;
}
svg {
width: 20px;
height: 20px;
}
`;
const FavoriteButton = styled(WatchButton)`
background: rgba(255, 255, 255, 0.1);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
`;
const MoviesGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
margin-top: 2rem;
`;
export default function HomePage() {
const { movies, featuredMovie, loading, error, totalPages, currentPage, setPage } = useMovies(1);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
if (loading && !movies.length) {
return (
<Container>
<div>Загрузка...</div>
</Container>
);
}
if (error) {
return (
<Container>
<div>{error}</div>
</Container>
);
}
const filteredMovies = selectedGenre
? movies.filter(movie => movie.genre_ids.includes(parseInt(selectedGenre)))
: movies;
return (
<Container>
{featuredMovie && (
<FeaturedMovie
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/original${featuredMovie.backdrop_path})`,
}}
>
<Overlay>
<FeaturedContent>
<GenreTags>
{featuredMovie.genres?.map(genre => (
<GenreTag key={genre.id}>{genre.name}</GenreTag>
))}
</GenreTags>
<Title>{featuredMovie.title}</Title>
<Description>{featuredMovie.overview}</Description>
<ButtonGroup>
<Link href={`/movie/${featuredMovie.id}`}>
<WatchButton>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
Смотреть
</WatchButton>
</Link>
<FavoriteButton as="button">
<HeartIcon />
В избранное
</FavoriteButton>
</ButtonGroup>
</FeaturedContent>
</Overlay>
</FeaturedMovie>
)}
<MoviesGrid>
{filteredMovies.map(movie => (
<MovieCard key={movie.id} movie={movie} />
))}
</MoviesGrid>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
</Container>
);
}

116
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
'use client';
import { useSession } from 'next-auth/react';
import styled from 'styled-components';
import GlassCard from '@/components/GlassCard';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
const Container = styled.div`
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-top: 80px;
background-color: #0a0a0a;
`;
const Content = styled.div`
width: 100%;
max-width: 600px;
padding: 2rem;
`;
const ProfileHeader = styled.div`
text-align: center;
margin-bottom: 2rem;
`;
const Avatar = styled.div`
width: 120px;
height: 120px;
border-radius: 50%;
background: #2196f3;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 3rem;
font-weight: bold;
margin: 0 auto 1rem;
border: 4px solid #fff;
`;
const Name = styled.h1`
color: #fff;
font-size: 2rem;
margin: 0;
`;
const Email = styled.p`
color: rgba(255, 255, 255, 0.7);
margin: 0.5rem 0 0;
`;
const SignOutButton = styled.button`
background: #ff4444;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
margin-top: 1rem;
&:hover {
background: #ff2020;
}
`;
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login');
}
}, [status, router]);
if (status === 'loading') {
return (
<Container>
<Content>
<GlassCard>
<div>Загрузка...</div>
</GlassCard>
</Content>
</Container>
);
}
if (!session) {
return null;
}
return (
<Container>
<Content>
<GlassCard>
<ProfileHeader>
<Avatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</Avatar>
<Name>{session.user?.name}</Name>
<Email>{session.user?.email}</Email>
</ProfileHeader>
<SignOutButton onClick={() => router.push('/settings')}>
Настройки
</SignOutButton>
</GlassCard>
</Content>
</Container>
);
}

32
src/app/providers.tsx Normal file
View File

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

12
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
'use client';
import SettingsContent from '@/components/SettingsContent';
import PageLayout from '@/components/PageLayout';
export default function SettingsPage() {
return (
<PageLayout>
<SettingsContent />
</PageLayout>
);
}

14
src/app/styles.tsx Normal file
View File

@@ -0,0 +1,14 @@
'use client';
import styled from 'styled-components';
export const MainContent = styled.main`
margin-left: 240px;
min-height: 100vh;
padding: 2rem;
@media (max-width: 768px) {
margin-left: 0;
padding-top: 4rem;
}
`;

View File

@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import styled from 'styled-components';
import Image from 'next/image';
import type { TVShowDetails } from '@/lib/api';
import MoviePlayer from '@/components/MoviePlayer';
const Container = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
`;
const ShowInfo = styled.div`
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
margin-bottom: 2rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
`;
const PosterContainer = styled.div`
position: relative;
width: 100%;
height: 450px;
border-radius: 0.5rem;
overflow: hidden;
`;
const InfoContent = styled.div`
color: white;
`;
const Title = styled.h1`
font-size: 2.5rem;
margin-bottom: 1rem;
`;
const Overview = styled.p`
margin-bottom: 1.5rem;
line-height: 1.6;
`;
const Stats = styled.div`
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
`;
const StatItem = styled.div`
span {
color: rgba(255, 255, 255, 0.6);
}
`;
const Section = styled.section`
margin-bottom: 2rem;
`;
const SectionTitle = styled.h2`
font-size: 1.5rem;
margin-bottom: 1rem;
color: white;
padding-top: 1rem;
`;
const PlayerSection = styled(Section)`
margin-top: 2rem;
min-height: 500px;
`;
const PlayerContainer = styled.div`
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
overflow: hidden;
`;
const CastGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
`;
const CastCard = styled.div`
background: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
`;
const CastImageContainer = styled.div`
position: relative;
width: 100%;
height: 225px;
`;
const CastInfo = styled.div`
padding: 0.75rem;
`;
const CastName = styled.h3`
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: white;
`;
const Character = styled.p`
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
`;
interface TVShowContentProps {
tvShowId: string;
initialShow: TVShowDetails;
}
export default function TVShowContent({ tvShowId, initialShow }: TVShowContentProps) {
const [show] = useState<TVShowDetails>(initialShow);
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<Container>
<ShowInfo>
<PosterContainer>
{show.poster_path && (
<Image
src={`https://image.tmdb.org/t/p/w500${show.poster_path}`}
alt={show.name}
fill
style={{ objectFit: 'cover' }}
priority
/>
)}
</PosterContainer>
<InfoContent>
<Title>{show.name}</Title>
<Overview>{show.overview}</Overview>
<Stats>
<StatItem>
<span>Дата выхода: </span>
{formatDate(show.first_air_date)}
</StatItem>
<StatItem>
<span>Сезонов: </span>
{show.number_of_seasons}
</StatItem>
<StatItem>
<span>Эпизодов: </span>
{show.number_of_episodes}
</StatItem>
</Stats>
</InfoContent>
</ShowInfo>
<PlayerSection>
<SectionTitle>Смотреть онлайн</SectionTitle>
<PlayerContainer>
<MoviePlayer
id={tvShowId}
title={show.name}
poster={show.poster_path ? `https://image.tmdb.org/t/p/w500${show.poster_path}` : ''}
imdbId={show.external_ids?.imdb_id}
/>
</PlayerContainer>
</PlayerSection>
{show.credits.cast.length > 0 && (
<Section>
<SectionTitle>В ролях</SectionTitle>
<CastGrid>
{show.credits.cast.slice(0, 12).map(actor => (
<CastCard key={actor.id}>
<CastImageContainer>
<Image
src={actor.profile_path
? `https://image.tmdb.org/t/p/w300${actor.profile_path}`
: '/placeholder.png'}
alt={actor.name}
fill
style={{ objectFit: 'cover' }}
/>
</CastImageContainer>
<CastInfo>
<CastName>{actor.name}</CastName>
<Character>{actor.character}</Character>
</CastInfo>
</CastCard>
))}
</CastGrid>
</Section>
)}
</Container>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import styled from 'styled-components';
import PageLayout from '@/components/PageLayout';
import TVShowContent from './TVShowContent';
import type { TVShowDetails } from '@/lib/api';
const Container = styled.div`
width: 100%;
min-height: 100vh;
padding: 0 24px;
`;
interface TVShowPageProps {
tvShowId: string;
show: TVShowDetails | null;
}
export default function TVShowPage({ tvShowId, show }: TVShowPageProps) {
if (!show) {
return (
<PageLayout>
<Container>
<div>Сериал не найден</div>
</Container>
</PageLayout>
);
}
return (
<PageLayout>
<Container>
<TVShowContent tvShowId={tvShowId} initialShow={show} />
</Container>
</PageLayout>
);
}

25
src/app/tv/[id]/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
import TVShowPage from './TVShowPage';
import { tvAPI } from '@/lib/api';
interface PageProps {
params: {
id: string;
};
searchParams: { [key: string]: string | string[] | undefined };
}
async function getData(id: string) {
try {
const response = await tvAPI.getShow(id);
return { id, show: response.data };
} catch (error) {
console.error('Error fetching show:', error);
return { id, show: null };
}
}
export default async function Page(props: PageProps) {
const { id } = props.params;
const data = await getData(id);
return <TVShowPage tvShowId={data.id} show={data.show} />;
}

View File

@@ -0,0 +1,231 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';
const Container = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
text-align: center;
`;
const Title = styled.h2`
font-size: 1.5rem;
font-weight: 600;
color: #fff;
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
margin-bottom: 2rem;
`;
const CodeInput = styled.input`
width: 100%;
padding: 1rem;
font-size: 2rem;
letter-spacing: 0.5rem;
text-align: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #fff;
transition: all 0.2s;
&:focus {
outline: none;
border-color: ${({ theme }) => theme.colors.primary};
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
}
&::placeholder {
letter-spacing: normal;
color: rgba(255, 255, 255, 0.3);
}
`;
const VerifyButton = styled.button`
width: 100%;
background: linear-gradient(to right, #2196f3, #1e88e5);
color: white;
padding: 1rem;
border-radius: 12px;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: linear-gradient(to right, #1e88e5, #1976d2);
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
`;
const ResendButton = styled.button`
background: none;
border: none;
color: ${({ theme }) => theme.colors.primary};
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ErrorMessage = styled.div`
color: #f44336;
font-size: 0.875rem;
margin-top: 0.5rem;
`;
export function VerificationClient({ email }: { email: string }) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
let timer: NodeJS.Timeout;
if (countdown > 0) {
timer = setInterval(() => {
setCountdown((prev) => prev - 1);
}, 1000);
}
return () => {
if (timer) clearInterval(timer);
};
}, [countdown]);
const handleVerify = async () => {
if (code.length !== 6) {
setError('Код должен состоять из 6 цифр');
return;
}
setIsLoading(true);
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('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally {
setIsLoading(false);
}
};
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('Не удалось отправить код');
}
setCountdown(60);
} catch (err) {
setError('Не удалось отправить код');
}
};
return (
<Container>
<div>
<Title>Подтвердите ваш email</Title>
<Subtitle>Мы отправили код подтверждения на {email}</Subtitle>
</div>
<div>
<CodeInput
type="text"
maxLength={6}
value={code}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
setCode(value);
setError('');
}}
placeholder="Введите код"
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
</div>
<div>
<VerifyButton
onClick={handleVerify}
disabled={isLoading || code.length !== 6}
>
{isLoading ? 'Проверка...' : 'Подтвердить'}
</VerifyButton>
<ResendButton
onClick={handleResend}
disabled={countdown > 0 || isLoading}
>
{countdown > 0
? `Отправить код повторно (${countdown}с)`
: 'Отправить код повторно'}
</ResendButton>
</div>
</Container>
);
}

119
src/app/verify/page.tsx Normal file
View File

@@ -0,0 +1,119 @@
'use client';
import { GlassCard } from '@/components/GlassCard';
import { VerificationClient } from './VerificationClient';
import styled from 'styled-components';
import { useSearchParams, useRouter } from 'next/navigation';
import { useEffect, Suspense } from 'react';
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 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;
`;
function VerifyContent() {
const searchParams = useSearchParams();
const router = useRouter();
const email = searchParams.get('email');
useEffect(() => {
if (!email) {
router.push('/login');
}
}, [email, router]);
if (!email) {
return null;
}
return (
<Container>
<GlowingBackground>
<Glow1 />
<Glow2 />
<Glow3 />
</GlowingBackground>
<Content>
<GlassCard>
<VerificationClient email={email} />
</GlassCard>
</Content>
</Container>
);
}
export default function VerificationPage() {
return (
<Suspense>
<VerifyContent />
</Suspense>
);
}