mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 01:48:50 +05:00
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:
147
src/app/admin/login/AdminLoginClient.tsx
Normal file
147
src/app/admin/login/AdminLoginClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/admin/login/page.tsx
Normal file
5
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminLoginClient from './AdminLoginClient';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
return <AdminLoginClient />;
|
||||
}
|
||||
43
src/app/api/admin/create/route.ts
Normal file
43
src/app/api/admin/create/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/app/api/admin/movies/route.ts
Normal file
28
src/app/api/admin/movies/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/admin/movies/toggle-visibility/route.ts
Normal file
46
src/app/api/admin/movies/toggle-visibility/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/send-verification/route.ts
Normal file
42
src/app/api/admin/send-verification/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/app/api/admin/toggle-admin/route.ts
Normal file
56
src/app/api/admin/toggle-admin/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/app/api/admin/users/toggle-admin/route.ts
Normal file
56
src/app/api/admin/users/toggle-admin/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/verify-code/route.ts
Normal file
42
src/app/api/admin/verify-code/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/app/api/auth/[...nextauth]/route.ts
Normal file
98
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
23
src/app/api/auth/check-verification/route.ts
Normal file
23
src/app/api/auth/check-verification/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/app/api/auth/register/route.ts
Normal file
58
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/auth/resend-code/route.ts
Normal file
48
src/app/api/auth/resend-code/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/app/api/auth/verify/route.ts
Normal file
51
src/app/api/auth/verify/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/app/api/movies/search/route.ts
Normal file
28
src/app/api/movies/search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/app/api/movies/sync/route.ts
Normal file
15
src/app/api/movies/sync/route.ts
Normal 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
42
src/app/error.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
51
src/app/globals.css
Normal file
51
src/app/globals.css
Normal 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
28
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
src/app/login/LoginClient.tsx
Normal file
317
src/app/login/LoginClient.tsx
Normal 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
129
src/app/login/page.tsx
Normal 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
6
src/app/metadata.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Neo Movies',
|
||||
description: 'Смотрите фильмы онлайн',
|
||||
};
|
||||
207
src/app/movie/[id]/MovieContent.tsx
Normal file
207
src/app/movie/[id]/MovieContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/movie/[id]/MoviePage.tsx
Normal file
37
src/app/movie/[id]/MoviePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/movie/[id]/page.tsx
Normal file
23
src/app/movie/[id]/page.tsx
Normal 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
178
src/app/not-found.tsx
Normal 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
193
src/app/page.tsx
Normal 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
116
src/app/profile/page.tsx
Normal 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
32
src/app/providers.tsx
Normal 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
12
src/app/settings/page.tsx
Normal 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
14
src/app/styles.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
216
src/app/tv/[id]/TVShowContent.tsx
Normal file
216
src/app/tv/[id]/TVShowContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/tv/[id]/TVShowPage.tsx
Normal file
37
src/app/tv/[id]/TVShowPage.tsx
Normal 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
25
src/app/tv/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
231
src/app/verify/VerificationClient.tsx
Normal file
231
src/app/verify/VerificationClient.tsx
Normal 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
119
src/app/verify/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user