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:
153
src/api.ts
Normal file
153
src/api.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'https://api.themoviedb.org/3';
|
||||
|
||||
if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) {
|
||||
throw new Error('TMDB_ACCESS_TOKEN is not defined in environment variables');
|
||||
}
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
export interface MovieDetails extends Movie {
|
||||
genres: Genre[];
|
||||
runtime: number;
|
||||
tagline: string;
|
||||
budget: number;
|
||||
revenue: number;
|
||||
videos: {
|
||||
results: Video[];
|
||||
};
|
||||
credits: {
|
||||
cast: Cast[];
|
||||
crew: Crew[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Cast {
|
||||
id: number;
|
||||
name: string;
|
||||
character: string;
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
export interface Crew {
|
||||
id: number;
|
||||
name: string;
|
||||
job: string;
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
export const moviesAPI = {
|
||||
// Получение популярных фильмов
|
||||
getPopular: (page = 1) =>
|
||||
api.get('/discover/movie', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100, // минимальное количество голосов
|
||||
'vote_average.gte': 1, // минимальный рейтинг
|
||||
sort_by: 'popularity.desc',
|
||||
include_adult: false,
|
||||
'primary_release_date.lte': new Date().toISOString().split('T')[0], // только вышедшие фильмы
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение данных о фильме по его TMDB ID
|
||||
getMovie: (id: string | number) =>
|
||||
api.get(`/movie/${id}`, {
|
||||
params: {
|
||||
language: 'ru-RU',
|
||||
append_to_response: 'credits,videos,similar' // дополнительная информация
|
||||
}
|
||||
}),
|
||||
|
||||
// Поиск фильмов
|
||||
searchMovies: (query: string, page = 1) =>
|
||||
api.get('/search/movie', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
include_adult: false,
|
||||
'primary_release_date.lte': new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение предстоящих фильмов
|
||||
getUpcoming: (page = 1) =>
|
||||
api.get('/movie/upcoming', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение лучших фильмов
|
||||
getTopRated: (page = 1) =>
|
||||
api.get('/movie/top_rated', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение фильмов по жанру
|
||||
getMoviesByGenre: (genreId: number, page = 1) =>
|
||||
api.get('/discover/movie', {
|
||||
params: {
|
||||
with_genres: genreId,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100,
|
||||
'vote_average.gte': 1,
|
||||
sort_by: 'popularity.desc',
|
||||
include_adult: false,
|
||||
'primary_release_date.lte': new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение IMDb ID по TMDB ID для плеера
|
||||
getImdbId: async (tmdbId: string | number) => {
|
||||
try {
|
||||
const response = await api.get(`/movie/${tmdbId}`, {
|
||||
params: {
|
||||
language: 'en-US', // Язык для IMDb ID
|
||||
},
|
||||
});
|
||||
return response.data.imdb_id;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении IMDb ID:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Получение видео по TMDB ID для плеера
|
||||
getVideo: async (tmdbId: string | number) => {
|
||||
try {
|
||||
const response = await api.get(`/movie/${tmdbId}/videos`, {
|
||||
params: {
|
||||
language: 'en-US', // Язык для видео
|
||||
},
|
||||
});
|
||||
return response.data.results;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении видео:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
42
src/components/AppLayout.tsx
Normal file
42
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { ReactNode } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const Layout = styled.div<{ $hasNavbar: boolean }>`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: #0E0E0E;
|
||||
`;
|
||||
|
||||
const Main = styled.main<{ $hasNavbar: boolean }>`
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
|
||||
${props => props.$hasNavbar && `
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 60px;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
margin-left: 240px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const hideNavbar = pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify');
|
||||
|
||||
return (
|
||||
<Layout $hasNavbar={!hideNavbar}>
|
||||
{!hideNavbar && <Navbar />}
|
||||
<Main $hasNavbar={!hideNavbar}>{children}</Main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
29
src/components/ClientLayout.tsx
Normal file
29
src/components/ClientLayout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import StyledComponentsRegistry from '@/lib/registry';
|
||||
import Navbar from './Navbar';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
background: '#0f172a',
|
||||
text: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<StyledComponentsRegistry>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Navbar />
|
||||
{children}
|
||||
<Toaster position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</StyledComponentsRegistry>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
13
src/components/DarkReaderFix.tsx
Normal file
13
src/components/DarkReaderFix.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function DarkReaderFix() {
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
html.removeAttribute('data-darkreader-mode');
|
||||
html.removeAttribute('data-darkreader-scheme');
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
17
src/components/GlassCard.tsx
Normal file
17
src/components/GlassCard.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export 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;
|
||||
`;
|
||||
|
||||
export default GlassCard;
|
||||
18
src/components/GoogleIcon.tsx
Normal file
18
src/components/GoogleIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<Image
|
||||
src="/google.svg"
|
||||
alt="Google"
|
||||
width={18}
|
||||
height={18}
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export default GoogleIcon;
|
||||
5
src/components/Icons/HeartIcon.tsx
Normal file
5
src/components/Icons/HeartIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export const HeartIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
80
src/components/Icons/Icons.tsx
Normal file
80
src/components/Icons/Icons.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
export const HomeIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 22V12h6v10" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CategoryIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HeartIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DownloadIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M7 10l5 5 5-5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 15V3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FriendsIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="9" cy="7" r="4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CommunityIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HistoryIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 6v6l4 2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LogoutIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M16 17l5-5-5-5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H9" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
);
|
||||
18
src/components/Icons/PlayIcon.tsx
Normal file
18
src/components/Icons/PlayIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export function PlayIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
src/components/LayoutContent.tsx
Normal file
28
src/components/LayoutContent.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { Providers } from './Providers';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import PageLayout from './PageLayout';
|
||||
|
||||
const MainContent = styled.div`
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
interface LayoutContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function LayoutContent({ children }: LayoutContentProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<PageLayout>
|
||||
<MainContent>
|
||||
{children}
|
||||
</MainContent>
|
||||
</PageLayout>
|
||||
<Toaster />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
78
src/components/MenuItem.tsx
Normal file
78
src/components/MenuItem.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface MenuItemProps {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
isActive?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const StyledMenuItem = styled.div<{ $active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.7)'};
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
background: ${props => props.$active ? 'rgba(255, 255, 255, 0.1)' : 'transparent'};
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: ${props => props.$active ? 1 : 0.7};
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemContent = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const SubLabel = styled.div`
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default function MenuItem({ href, icon, label, subLabel, isActive, onClick }: MenuItemProps) {
|
||||
const content = (
|
||||
<StyledMenuItem $active={isActive} onClick={onClick}>
|
||||
{icon}
|
||||
<ItemContent>
|
||||
<Label>{label}</Label>
|
||||
{subLabel && <SubLabel>{subLabel}</SubLabel>}
|
||||
</ItemContent>
|
||||
</StyledMenuItem>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} passHref style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
93
src/components/MovieCard.tsx
Normal file
93
src/components/MovieCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import styled from 'styled-components';
|
||||
import { Movie } from '@/types/movie';
|
||||
|
||||
interface MovieCardProps {
|
||||
movie: Movie;
|
||||
}
|
||||
|
||||
export default function MovieCard({ movie }: MovieCardProps) {
|
||||
const getRatingColor = (rating: number) => {
|
||||
if (rating >= 7) return '#4CAF50';
|
||||
if (rating >= 5) return '#FFC107';
|
||||
return '#F44336';
|
||||
};
|
||||
|
||||
const posterUrl = movie.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
|
||||
: '/placeholder.jpg';
|
||||
|
||||
return (
|
||||
<Card href={`/movie/${movie.id}`}>
|
||||
<PosterWrapper>
|
||||
<Poster
|
||||
src={posterUrl}
|
||||
alt={movie.title}
|
||||
width={200}
|
||||
height={300}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<Rating style={{ backgroundColor: getRatingColor(movie.vote_average) }}>
|
||||
{movie.vote_average.toFixed(1)}
|
||||
</Rating>
|
||||
</PosterWrapper>
|
||||
<Content>
|
||||
<Title>{movie.title}</Title>
|
||||
<Year>{new Date(movie.release_date).getFullYear()}</Year>
|
||||
</Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const Card = styled(Link)`
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #242424;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const PosterWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 2/3;
|
||||
`;
|
||||
|
||||
const Poster = styled(Image)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Year = styled.div`
|
||||
font-size: 12px;
|
||||
color: #808191;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Rating = styled.div`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
`;
|
||||
234
src/components/MoviePlayer.tsx
Normal file
234
src/components/MoviePlayer.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { moviesAPI } from '@/lib/api';
|
||||
|
||||
const PlayerContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const RetryButton = styled.button`
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
`;
|
||||
|
||||
const DownloadMessage = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(13, 37, 73, 0.8);
|
||||
border: 1px solid rgba(33, 150, 243, 0.2);
|
||||
border-radius: 8px;
|
||||
color: rgba(33, 150, 243, 0.9);
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MoviePlayerProps {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
||||
export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerProps) {
|
||||
const { settings, isInitialized } = useSettings();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPlayer, setCurrentPlayer] = useState(settings.defaultPlayer);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
setCurrentPlayer(settings.defaultPlayer);
|
||||
}
|
||||
}, [settings.defaultPlayer, isInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!imdbId) {
|
||||
const newImdbId = await moviesAPI.getImdbId(id);
|
||||
if (!newImdbId) {
|
||||
throw new Error('IMDb ID не найден');
|
||||
}
|
||||
imdbId = newImdbId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching IMDb ID:', err);
|
||||
setError('Не удалось загрузить плеер. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImdbId();
|
||||
}, [id, imdbId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.defaultPlayer === 'lumex') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Очищаем контейнер при изменении плеера
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
|
||||
const playerDiv = document.createElement('div');
|
||||
playerDiv.className = 'kinobox_player';
|
||||
containerRef.current?.appendChild(playerDiv);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://kinobox.tv/kinobox.min.js';
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
if (window.kbox && containerRef.current) {
|
||||
const playerConfig = {
|
||||
search: {
|
||||
imdb: imdbId,
|
||||
title: title
|
||||
},
|
||||
menu: {
|
||||
enable: false,
|
||||
default: 'menu_list',
|
||||
mobile: 'menu_button',
|
||||
format: '{N} :: {T} ({Q})',
|
||||
limit: 5,
|
||||
open: false,
|
||||
},
|
||||
notFoundMessage: 'Видео не найдено.',
|
||||
players: {
|
||||
alloha: { enable: settings.defaultPlayer === 'alloha', position: 1 },
|
||||
collaps: { enable: settings.defaultPlayer === 'collaps', position: 2 },
|
||||
lumex: { enable: settings.defaultPlayer === 'lumex', position: 3 }
|
||||
},
|
||||
params: {
|
||||
all: {
|
||||
poster: poster
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.kbox('.kinobox_player', playerConfig);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
const existingScript = document.querySelector('script[src="https://kinobox.tv/kinobox.min.js"]');
|
||||
if (existingScript) {
|
||||
document.body.removeChild(existingScript);
|
||||
}
|
||||
};
|
||||
}, [id, title, poster, imdbId, settings.defaultPlayer]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<div>{error}</div>
|
||||
<RetryButton onClick={handleRetry}>Попробовать снова</RetryButton>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerContainer>
|
||||
{settings.defaultPlayer === 'lumex' && imdbId ? (
|
||||
<StyledIframe
|
||||
src={`${process.env.NEXT_PUBLIC_LUMEX_URL}?imdb_id=${imdbId}`}
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%', position: 'absolute' }} />
|
||||
{loading && <LoadingContainer>Загрузка плеера...</LoadingContainer>}
|
||||
</>
|
||||
)}
|
||||
</PlayerContainer>
|
||||
{settings.defaultPlayer !== 'lumex' && (
|
||||
<DownloadMessage>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Для возможности скачивания фильма выберите плеер Lumex в настройках
|
||||
</DownloadMessage>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
406
src/components/Navbar.tsx
Normal file
406
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import SearchModal from './SearchModal';
|
||||
|
||||
// Типы
|
||||
type MenuItem = {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
// Компоненты
|
||||
const DesktopSidebar = styled.aside`
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: rgba(18, 18, 23, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 1rem;
|
||||
z-index: 40;
|
||||
|
||||
@media (min-width: 769px) {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const LogoContainer = styled.div`
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const MenuContainer = styled.nav`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const SidebarMenuItem = styled.div<{ $active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: ${props => props.$active ? 'white' : 'rgba(255, 255, 255, 0.7)'};
|
||||
background: ${props => props.$active ? 'rgba(255, 255, 255, 0.1)' : 'transparent'};
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileNav = styled.nav`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(18, 18, 23, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 50;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
|
||||
@media (min-width: 769px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Logo = styled(Link)`
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
color: #3b82f6;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileMenuButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileMenu = styled.div<{ $isOpen: boolean }>`
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(18, 18, 23, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateX(${props => props.$isOpen ? '0' : '100%'});
|
||||
transition: transform 0.3s ease-in-out;
|
||||
padding: 1rem;
|
||||
z-index: 49;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (min-width: 769px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileMenuItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 1rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const UserProfile = styled.div`
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const UserButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const UserAvatar = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
min-width: 0;
|
||||
|
||||
div:first-child {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
const AuthButtons = styled.div`
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
export default function Navbar() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Скрываем навбар на определенных страницах
|
||||
if (pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleNavigation = (href: string, onClick?: () => void) => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else if (href !== '#') {
|
||||
router.push(href);
|
||||
}
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Главная',
|
||||
href: '/',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'Поиск',
|
||||
href: '#',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => setIsSearchOpen(true)
|
||||
},
|
||||
{
|
||||
label: 'Категории',
|
||||
href: '/categories',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'Избранное',
|
||||
href: '/favorites',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'Настройки',
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar */}
|
||||
<DesktopSidebar>
|
||||
<LogoContainer>
|
||||
<div onClick={() => router.push('/')} style={{ cursor: 'pointer' }}>
|
||||
<Logo as="div">
|
||||
Neo <span>Movies</span>
|
||||
</Logo>
|
||||
</div>
|
||||
</LogoContainer>
|
||||
|
||||
<MenuContainer>
|
||||
{menuItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleNavigation(item.href, item.onClick)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<SidebarMenuItem
|
||||
as="div"
|
||||
$active={pathname === item.href}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
))}
|
||||
</MenuContainer>
|
||||
|
||||
{session ? (
|
||||
<UserProfile>
|
||||
<UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}>
|
||||
<UserAvatar>
|
||||
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
|
||||
</UserAvatar>
|
||||
<UserInfo>
|
||||
<div>{session.user?.name}</div>
|
||||
<div>{session.user?.email}</div>
|
||||
</UserInfo>
|
||||
</UserButton>
|
||||
</UserProfile>
|
||||
) : (
|
||||
<AuthButtons>
|
||||
<div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}>
|
||||
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
|
||||
Войти
|
||||
</MobileMenuItem>
|
||||
</div>
|
||||
</AuthButtons>
|
||||
)}
|
||||
</DesktopSidebar>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<MobileNav>
|
||||
<Logo href="/">
|
||||
Neo <span>Movies</span>
|
||||
</Logo>
|
||||
<MobileMenuButton onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</MobileMenuButton>
|
||||
</MobileNav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<MobileMenu $isOpen={isMobileMenuOpen}>
|
||||
{session ? (
|
||||
<UserProfile>
|
||||
<UserButton onClick={() => signOut()}>
|
||||
<UserAvatar>
|
||||
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
|
||||
</UserAvatar>
|
||||
<UserInfo>
|
||||
<div>{session.user?.name}</div>
|
||||
<div>{session.user?.email}</div>
|
||||
</UserInfo>
|
||||
</UserButton>
|
||||
</UserProfile>
|
||||
) : null}
|
||||
|
||||
{menuItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleNavigation(item.href, item.onClick)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<MobileMenuItem
|
||||
as="div"
|
||||
style={{
|
||||
background: pathname === item.href ? 'rgba(255, 255, 255, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</MobileMenuItem>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!session && (
|
||||
<AuthButtons>
|
||||
<div onClick={() => {
|
||||
router.push('/login');
|
||||
setIsMobileMenuOpen(false);
|
||||
}} style={{ cursor: 'pointer' }}>
|
||||
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
|
||||
Войти
|
||||
</MobileMenuItem>
|
||||
</div>
|
||||
</AuthButtons>
|
||||
)}
|
||||
</MobileMenu>
|
||||
|
||||
{/* Search Modal */}
|
||||
{isSearchOpen && (
|
||||
<SearchModal onClose={() => setIsSearchOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/components/Notification.tsx
Normal file
58
src/components/Notification.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
|
||||
const slideIn = keyframes`
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled.div<{ type: 'success' | 'error' | 'info' }>`
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background: ${({ type }) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '#4caf50';
|
||||
case 'error':
|
||||
return '#f44336';
|
||||
case 'info':
|
||||
return '#2196f3';
|
||||
}
|
||||
}};
|
||||
color: white;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
animation: ${slideIn} 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
interface NotificationProps {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export default function Notification({
|
||||
message,
|
||||
type,
|
||||
onClose,
|
||||
duration = 3000,
|
||||
}: NotificationProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
return <Container type={type}>{message}</Container>;
|
||||
}
|
||||
88
src/components/PageLayout.tsx
Normal file
88
src/components/PageLayout.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const Layout = styled.div`
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const MainContent = styled.main<{ $isSettingsPage: boolean }>`
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
${props => props.$isSettingsPage && `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 2rem;
|
||||
`}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-left: 0;
|
||||
padding-top: ${props => props.$isSettingsPage ? 'calc(60px + 2rem)' : '60px'};
|
||||
}
|
||||
`;
|
||||
|
||||
const NotFoundContent = styled.main`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 6rem;
|
||||
margin: 0;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.5rem;
|
||||
margin: 1rem 0 2rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2196f3;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isSettingsPage = pathname === '/settings';
|
||||
const is404Page = pathname === '/404' || pathname.includes('/not-found');
|
||||
|
||||
if (is404Page) {
|
||||
return (
|
||||
<NotFoundContent>
|
||||
<h1>404</h1>
|
||||
<p>Страница не найдена</p>
|
||||
<a href="/">Вернуться на главную</a>
|
||||
</NotFoundContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Navbar />
|
||||
<MainContent $isSettingsPage={isSettingsPage}>
|
||||
{children}
|
||||
</MainContent>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
106
src/components/Pagination.tsx
Normal file
106
src/components/Pagination.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const PaginationContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
`;
|
||||
|
||||
const PageButton = styled.button<{ $active?: boolean }>`
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: ${props => props.$active ? props.theme.colors.primary : 'rgba(255, 255, 255, 0.1)'};
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.$active ? props.theme.colors.primary : 'rgba(255, 255, 255, 0.2)'};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const PageInfo = styled.span`
|
||||
color: white;
|
||||
padding: 0 1rem;
|
||||
`;
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const maxVisiblePages = 5;
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
let start = Math.max(1, currentPage - halfVisible);
|
||||
let end = Math.min(totalPages, start + maxVisiblePages - 1);
|
||||
|
||||
if (end - start + 1 < maxVisiblePages) {
|
||||
start = Math.max(1, end - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
};
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<PaginationContainer>
|
||||
<PageButton
|
||||
onClick={() => handlePageClick(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
«
|
||||
</PageButton>
|
||||
<PageButton
|
||||
onClick={() => handlePageClick(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
‹
|
||||
</PageButton>
|
||||
|
||||
{getPageNumbers().map(page => (
|
||||
<PageButton
|
||||
key={page}
|
||||
$active={page === currentPage}
|
||||
onClick={() => handlePageClick(page)}
|
||||
>
|
||||
{page}
|
||||
</PageButton>
|
||||
))}
|
||||
|
||||
<PageButton
|
||||
onClick={() => handlePageClick(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
›
|
||||
</PageButton>
|
||||
<PageButton
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
»
|
||||
</PageButton>
|
||||
</PaginationContainer>
|
||||
);
|
||||
}
|
||||
34
src/components/Providers.tsx
Normal file
34
src/components/Providers.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { GlobalStyles } from '@/styles/GlobalStyles';
|
||||
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#2196f3',
|
||||
background: '#0a0a0a',
|
||||
surface: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||
error: '#ff5252',
|
||||
success: '#4caf50',
|
||||
},
|
||||
breakpoints: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
},
|
||||
};
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider refetchInterval={0}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
177
src/components/SearchModal.tsx
Normal file
177
src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Movie, TVShow } from '@/lib/api';
|
||||
import SearchResults from './SearchResults';
|
||||
|
||||
const Overlay = styled.div<{ $isOpen: boolean }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: ${props => props.$isOpen ? 'flex' : 'none'};
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 100px;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
`;
|
||||
|
||||
const Modal = styled.div`
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SearchHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const LoadingSpinner = styled.div`
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface SearchModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
inputRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const searchTimeout = setTimeout(async () => {
|
||||
if (query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/movies/search?query=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
setResults(data.results || []);
|
||||
} catch (error) {
|
||||
console.error('Error searching:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(searchTimeout);
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay $isOpen={true} onKeyDown={handleKeyDown}>
|
||||
<Modal ref={modalRef}>
|
||||
<SearchHeader>
|
||||
<SearchIcon>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</SearchIcon>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Поиск фильмов и сериалов..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<CloseButton onClick={onClose}>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</CloseButton>
|
||||
)}
|
||||
</SearchHeader>
|
||||
{results.length > 0 && <SearchResults results={results} onItemClick={onClose} />}
|
||||
</Modal>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
109
src/components/SearchResults.tsx
Normal file
109
src/components/SearchResults.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Movie, TVShow } from '@/lib/api';
|
||||
|
||||
const ResultsContainer = styled.div`
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
const ResultItem = styled(Link)`
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
gap: 1rem;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const PosterContainer = styled.div`
|
||||
position: relative;
|
||||
width: 45px;
|
||||
height: 68px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
`;
|
||||
|
||||
const ItemInfo = styled.div`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const Year = styled.span`
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
`;
|
||||
|
||||
const Type = styled.span`
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
`;
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: (Movie | TVShow)[];
|
||||
onItemClick: () => void;
|
||||
}
|
||||
|
||||
export default function SearchResults({ results, onItemClick }: SearchResultsProps) {
|
||||
const getYear = (date: string) => {
|
||||
if (!date) return '';
|
||||
return new Date(date).getFullYear();
|
||||
};
|
||||
|
||||
const isMovie = (item: Movie | TVShow): item is Movie => {
|
||||
return 'title' in item;
|
||||
};
|
||||
|
||||
return (
|
||||
<ResultsContainer>
|
||||
{results.map((item) => (
|
||||
<ResultItem
|
||||
key={item.id}
|
||||
href={isMovie(item) ? `/movie/${item.id}` : `/tv/${item.id}`}
|
||||
onClick={onItemClick}
|
||||
>
|
||||
<PosterContainer>
|
||||
<Image
|
||||
src={item.poster_path
|
||||
? `https://image.tmdb.org/t/p/w92${item.poster_path}`
|
||||
: '/placeholder.png'}
|
||||
alt={isMovie(item) ? item.title : item.name}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</PosterContainer>
|
||||
<ItemInfo>
|
||||
<Title>
|
||||
{isMovie(item) ? item.title : item.name}
|
||||
<Type>{isMovie(item) ? 'Фильм' : 'Сериал'}</Type>
|
||||
</Title>
|
||||
<Year>
|
||||
{getYear(isMovie(item) ? item.release_date : item.first_air_date)}
|
||||
</Year>
|
||||
</ItemInfo>
|
||||
</ResultItem>
|
||||
))}
|
||||
</ResultsContainer>
|
||||
);
|
||||
}
|
||||
109
src/components/SettingsContent.tsx
Normal file
109
src/components/SettingsContent.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding: 0 1rem;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2rem;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const PlayersList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PlayerCard = styled.div<{ $isSelected: boolean }>`
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid ${props => props.$isSelected ? '#2196f3' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerName = styled.h2`
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const PlayerDescription = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
const SaveButton = styled.button`
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function SettingsContent() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const players = [
|
||||
{
|
||||
id: 'alloha',
|
||||
name: 'Alloha',
|
||||
description: 'Основной плеер с высоким качеством',
|
||||
},
|
||||
{
|
||||
id: 'collaps',
|
||||
name: 'Collaps',
|
||||
description: 'Альтернативный плеер с хорошей стабильностью',
|
||||
},
|
||||
{
|
||||
id: 'lumex',
|
||||
name: 'Lumex',
|
||||
description: 'Плеер с возможностью скачивания фильмов',
|
||||
},
|
||||
];
|
||||
|
||||
const handlePlayerSelect = (playerId: string) => {
|
||||
updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>Настройки плеера</Title>
|
||||
<PlayersList>
|
||||
{players.map((player) => (
|
||||
<PlayerCard
|
||||
key={player.id}
|
||||
$isSelected={settings.defaultPlayer === player.id}
|
||||
onClick={() => handlePlayerSelect(player.id)}
|
||||
>
|
||||
<PlayerName>{player.name}</PlayerName>
|
||||
<PlayerDescription>{player.description}</PlayerDescription>
|
||||
</PlayerCard>
|
||||
))}
|
||||
</PlayersList>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
12
src/components/StyleProvider.tsx
Normal file
12
src/components/StyleProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import GlobalStyles from '@/styles/GlobalStyles';
|
||||
|
||||
export default function StyleProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/VerificationCodeInput.tsx
Normal file
104
src/components/VerificationCodeInput.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin: 1rem 0;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 3rem;
|
||||
height: 3.5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&: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);
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
length?: number;
|
||||
onChange: (code: string) => void;
|
||||
}
|
||||
|
||||
export function VerificationCodeInput({ length = 6, onChange }: Props) {
|
||||
const [code, setCode] = useState<string[]>(Array(length).fill(''));
|
||||
const inputs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const processInput = (e: React.ChangeEvent<HTMLInputElement>, slot: number) => {
|
||||
const num = e.target.value;
|
||||
if (/[^0-9]/.test(num)) return;
|
||||
|
||||
const newCode = [...code];
|
||||
newCode[slot] = num;
|
||||
setCode(newCode);
|
||||
|
||||
const combinedCode = newCode.join('');
|
||||
onChange(combinedCode);
|
||||
|
||||
if (slot !== length - 1 && num) {
|
||||
inputs.current[slot + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>, slot: number) => {
|
||||
if (e.key === 'Backspace' && !code[slot] && slot !== 0) {
|
||||
const newCode = [...code];
|
||||
newCode[slot - 1] = '';
|
||||
setCode(newCode);
|
||||
inputs.current[slot - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const paste = e.clipboardData.getData('text');
|
||||
const pasteNumbers = paste.match(/[0-9]/g);
|
||||
|
||||
if (!pasteNumbers) return;
|
||||
|
||||
const newCode = [...code];
|
||||
pasteNumbers.forEach((num, i) => {
|
||||
if (i >= length) return;
|
||||
newCode[i] = num;
|
||||
inputs.current[i]?.value = num;
|
||||
});
|
||||
|
||||
setCode(newCode);
|
||||
onChange(newCode.join(''));
|
||||
inputs.current[Math.min(pasteNumbers.length, length - 1)]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{code.map((num, idx) => (
|
||||
<Input
|
||||
key={idx}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={num}
|
||||
autoFocus={!code[0].length && idx === 0}
|
||||
onChange={(e) => processInput(e, idx)}
|
||||
onKeyUp={(e) => onKeyUp(e, idx)}
|
||||
onPaste={handlePaste}
|
||||
ref={(ref) => inputs.current[idx] = ref}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
97
src/components/admin/MovieSearch.tsx
Normal file
97
src/components/admin/MovieSearch.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
interface Movie {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
release_date: string;
|
||||
vote_average: number;
|
||||
poster_path: string | null;
|
||||
genre_ids: number[];
|
||||
}
|
||||
|
||||
export default function MovieSearch() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchMovies = debounce(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/movies/search?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results || []);
|
||||
} catch (error) {
|
||||
console.error('Error searching movies:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
searchMovies(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
placeholder="Поиск фильмов..."
|
||||
className="w-full px-4 py-2 bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{searchResults.map((movie) => (
|
||||
<div
|
||||
key={movie.id}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="aspect-w-2 aspect-h-3">
|
||||
<img
|
||||
src={
|
||||
movie.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
|
||||
: '/placeholder.jpg'
|
||||
}
|
||||
alt={movie.title}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-lg mb-2">{movie.title}</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
{new Date(movie.release_date).getFullYear()} • {movie.vote_average.toFixed(1)} ⭐
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 line-clamp-3 mb-4">
|
||||
{movie.overview}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/data/movies.ts
Normal file
75
src/data/movies.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface Movie {
|
||||
title: string;
|
||||
description: string;
|
||||
year: number;
|
||||
rating: number;
|
||||
posterUrl: string;
|
||||
genres: string[];
|
||||
director: string;
|
||||
cast: string[];
|
||||
duration: number;
|
||||
trailerUrl?: string;
|
||||
}
|
||||
|
||||
export const movies: Movie[] = [
|
||||
{
|
||||
title: "Миссия: Красный",
|
||||
description: "Санта-Клаус под кодовым именем Красный похищен. Начальник службы безопасности Северного полюса должен объединиться с самым известным в мире охотником за головами. Вместе они начинают кругосветную миссию по спасению Рождества.",
|
||||
year: 2023,
|
||||
rating: 7.0,
|
||||
posterUrl: "/movies/red-one.jpg",
|
||||
genres: ["боевик", "фэнтези", "комедия"],
|
||||
director: "Джейк Каздан",
|
||||
cast: ["Дуэйн Джонсон", "Крис Эванс", "Кирнан Шипка"],
|
||||
duration: 118,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=example1"
|
||||
},
|
||||
{
|
||||
title: "Веном 2",
|
||||
description: "Более чем через год после событий первого фильма журналист Эдди Брок пытается приспособиться к жизни в качестве хозяина инопланетного симбиота Венома.",
|
||||
year: 2021,
|
||||
rating: 6.3,
|
||||
posterUrl: "/movies/venom.jpg",
|
||||
genres: ["боевик", "фантастика", "триллер"],
|
||||
director: "Энди Серкис",
|
||||
cast: ["Том Харди", "Мишель Уильямс", "Вуди Харрельсон"],
|
||||
duration: 97,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=example2"
|
||||
},
|
||||
{
|
||||
title: "Мауи",
|
||||
description: "Юная Моана, дочь вождя маленького племени на острове в Тихом океане, больше всего на свете мечтает о приключениях и решает отправиться в опасное морское путешествие.",
|
||||
year: 2023,
|
||||
rating: 7.0,
|
||||
posterUrl: "/movies/maui.jpg",
|
||||
genres: ["мультфильм", "приключения", "семейный"],
|
||||
director: "Рон Клементс",
|
||||
cast: ["Аулии Кравальо", "Дуэйн Джонсон"],
|
||||
duration: 107,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=example3"
|
||||
},
|
||||
{
|
||||
title: "Мулафа",
|
||||
description: "История об отважном львенке по имени Симба, покорившая сердца миллионов людей по всему миру, возвращается на большие экраны в новом зрелищном художественном фильме Disney.",
|
||||
year: 2023,
|
||||
rating: 6.7,
|
||||
posterUrl: "/movies/mulafa.jpg",
|
||||
genres: ["приключения", "драма", "семейный"],
|
||||
director: "Джон Фавро",
|
||||
cast: ["Дональд Гловер", "Бейонсе Ноулз-Картер", "Джеймс Эрл Джонс"],
|
||||
duration: 118,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=example4"
|
||||
},
|
||||
{
|
||||
title: "Хищные земли",
|
||||
description: "В суровых условиях Аляски группа людей сталкивается с опасными хищниками и борется за выживание в дикой природе.",
|
||||
year: 2023,
|
||||
rating: 6.4,
|
||||
posterUrl: "/movies/predator-lands.jpg",
|
||||
genres: ["триллер", "приключения", "драма"],
|
||||
director: "Джон Дэвис",
|
||||
cast: ["Лиам Нисон", "Фрэнк Грилло", "Дермот Малруни"],
|
||||
duration: 108,
|
||||
trailerUrl: "https://www.youtube.com/watch?v=example5"
|
||||
}
|
||||
];
|
||||
16
src/eslint.config.mjs
Normal file
16
src/eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
80
src/hooks/useMovies.ts
Normal file
80
src/hooks/useMovies.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { moviesAPI } from '@/lib/api';
|
||||
import type { Movie } from '@/lib/api';
|
||||
|
||||
export function useMovies(initialPage = 1) {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [featuredMovie, setFeaturedMovie] = useState<Movie | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
const filterMovies = useCallback((movies: Movie[]) => {
|
||||
return movies.filter(movie => {
|
||||
if (movie.vote_average === 0) return false;
|
||||
const hasRussianLetters = /[а-яА-ЯёЁ]/.test(movie.title);
|
||||
if (!hasRussianLetters) return false;
|
||||
if (/^\d+$/.test(movie.title)) return false;
|
||||
const releaseDate = new Date(movie.release_date);
|
||||
const now = new Date();
|
||||
if (releaseDate > now) return false;
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchFeaturedMovie = useCallback(async () => {
|
||||
try {
|
||||
const response = await moviesAPI.getPopular(1);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
if (filteredMovies.length > 0) {
|
||||
const featuredMovieData = await moviesAPI.getMovie(filteredMovies[0].id);
|
||||
setFeaturedMovie(featuredMovieData.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке featured фильма:', err);
|
||||
}
|
||||
}, [filterMovies]);
|
||||
|
||||
const fetchMovies = useCallback(async (pageNum: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await moviesAPI.getPopular(pageNum);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
setMovies(filteredMovies);
|
||||
setTotalPages(response.data.total_pages);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке фильмов:', err);
|
||||
setError('Произошла ошибка при загрузке фильмов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterMovies]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeaturedMovie();
|
||||
}, [fetchFeaturedMovie]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMovies(page);
|
||||
}, [page, fetchMovies]);
|
||||
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setPage(newPage);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
movies,
|
||||
featuredMovie,
|
||||
loading,
|
||||
error,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
setPage: handlePageChange
|
||||
};
|
||||
}
|
||||
100
src/hooks/useSearch.ts
Normal file
100
src/hooks/useSearch.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { moviesAPI } from '@/lib/api';
|
||||
import type { Movie } from '@/lib/api';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function useSearch() {
|
||||
const [results, setResults] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentQuery, setCurrentQuery] = useState('');
|
||||
const [searchFailed, setSearchFailed] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const filterMovies = (movies: Movie[]) => {
|
||||
return movies.filter(movie => {
|
||||
if (movie.vote_average === 0) return false;
|
||||
const hasRussianLetters = /[а-яА-ЯёЁ]/.test(movie.title);
|
||||
if (!hasRussianLetters) return false;
|
||||
if (/^\d+$/.test(movie.title)) return false;
|
||||
const releaseDate = new Date(movie.release_date);
|
||||
const now = new Date();
|
||||
if (releaseDate > now) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const searchMovies = async (query: string) => {
|
||||
try {
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
setCurrentPage(1);
|
||||
setCurrentQuery('');
|
||||
setSearchFailed(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSearchFailed(false);
|
||||
setCurrentQuery(query);
|
||||
setCurrentPage(1);
|
||||
|
||||
const response = await moviesAPI.searchMovies(query, 1);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
|
||||
if (filteredMovies.length === 0) {
|
||||
setSearchFailed(true);
|
||||
}
|
||||
|
||||
setResults(filteredMovies);
|
||||
setHasMore(response.data.total_pages > 1);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при поиске:', err);
|
||||
setError('Произошла ошибка при поиске');
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !hasMore || !currentQuery) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const response = await moviesAPI.searchMovies(currentQuery, nextPage);
|
||||
const filteredMovies = filterMovies(response.data.results);
|
||||
|
||||
setResults(prev => [...prev, ...filteredMovies]);
|
||||
setCurrentPage(nextPage);
|
||||
setHasMore(nextPage < response.data.total_pages && nextPage < 5); // Ограничиваем до 5 страниц
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке дополнительных результатов:', err);
|
||||
setError('Произошла ошибка при загрузке дополнительных результатов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
results,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
searchFailed,
|
||||
searchMovies,
|
||||
loadMore
|
||||
};
|
||||
}
|
||||
67
src/hooks/useSettings.ts
Normal file
67
src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Settings {
|
||||
theme: 'light' | 'dark';
|
||||
language: 'ru' | 'en';
|
||||
notifications: boolean;
|
||||
defaultPlayer: 'alloha' | 'collaps' | 'lumex';
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
theme: 'dark',
|
||||
language: 'ru',
|
||||
notifications: true,
|
||||
defaultPlayer: 'alloha',
|
||||
};
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('settings');
|
||||
if (savedSettings) {
|
||||
const parsedSettings = JSON.parse(savedSettings);
|
||||
setSettings(prev => ({ ...defaultSettings, ...parsedSettings }));
|
||||
}
|
||||
setIsInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
setSettings(defaultSettings);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
try {
|
||||
localStorage.setItem('settings', JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
}
|
||||
}
|
||||
}, [settings, isInitialized]);
|
||||
|
||||
const updateSettings = (newSettings: Partial<Settings>) => {
|
||||
setSettings(prev => ({ ...prev, ...newSettings }));
|
||||
};
|
||||
|
||||
const resetSettings = () => {
|
||||
setSettings(defaultSettings);
|
||||
try {
|
||||
localStorage.setItem('settings', JSON.stringify(defaultSettings));
|
||||
} catch (error) {
|
||||
console.error('Error resetting settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
146
src/hooks/useUser.ts
Normal file
146
src/hooks/useUser.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn, signOut } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PendingRegistration {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const router = useRouter();
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [pendingRegistration, setPendingRegistration] = useState<PendingRegistration | null>(null);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
// Сначала проверяем, верифицирован ли аккаунт
|
||||
const verificationCheck = await fetch('/api/auth/check-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
const { isVerified } = await verificationCheck.json();
|
||||
|
||||
if (!isVerified) {
|
||||
// Если аккаунт не верифицирован, отправляем новый код и переходим к верификации
|
||||
const verificationResponse = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!verificationResponse.ok) {
|
||||
throw new Error('Не удалось отправить код подтверждения');
|
||||
}
|
||||
|
||||
setIsVerifying(true);
|
||||
setPendingRegistration({ email, password });
|
||||
return;
|
||||
}
|
||||
|
||||
// Если аккаунт верифицирован, выполняем вход
|
||||
const result = await signIn('credentials', {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
try {
|
||||
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 verificationResponse = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!verificationResponse.ok) {
|
||||
throw new Error('Не удалось отправить код подтверждения');
|
||||
}
|
||||
|
||||
setIsVerifying(true);
|
||||
setPendingRegistration({ email, password, name });
|
||||
return { needsVerification: true };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCode = async (code: string) => {
|
||||
if (!pendingRegistration) {
|
||||
throw new Error('Нет ожидающей регистрации');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: pendingRegistration.email,
|
||||
code
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Неверный код подтверждения');
|
||||
}
|
||||
|
||||
// После успешной верификации выполняем вход
|
||||
const result = await signIn('credentials', {
|
||||
redirect: false,
|
||||
email: pendingRegistration.email,
|
||||
password: pendingRegistration.password,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
setIsVerifying(false);
|
||||
setPendingRegistration(null);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
signOut({ callbackUrl: '/login' });
|
||||
};
|
||||
|
||||
return {
|
||||
login,
|
||||
register,
|
||||
verifyCode,
|
||||
logout,
|
||||
isVerifying,
|
||||
pendingRegistration
|
||||
};
|
||||
}
|
||||
255
src/lib/api.ts
Normal file
255
src/lib/api.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'https://api.themoviedb.org/3';
|
||||
|
||||
if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) {
|
||||
throw new Error('TMDB_ACCESS_TOKEN is not defined in environment variables');
|
||||
}
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
release_date: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
genre_ids: number[];
|
||||
runtime?: number;
|
||||
genres?: Array<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
export interface MovieDetails extends Movie {
|
||||
genres: Genre[];
|
||||
runtime: number;
|
||||
tagline: string;
|
||||
budget: number;
|
||||
revenue: number;
|
||||
videos: {
|
||||
results: Video[];
|
||||
};
|
||||
credits: {
|
||||
cast: Cast[];
|
||||
crew: Crew[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TVShow {
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
first_air_date: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
genre_ids: number[];
|
||||
}
|
||||
|
||||
export interface TVShowDetails extends TVShow {
|
||||
genres: Genre[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
tagline: string;
|
||||
credits: {
|
||||
cast: Cast[];
|
||||
crew: Crew[];
|
||||
};
|
||||
seasons: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
episode_count: number;
|
||||
poster_path: string | null;
|
||||
}>;
|
||||
external_ids?: {
|
||||
imdb_id: string | null;
|
||||
tvdb_id: number | null;
|
||||
tvrage_id: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Cast {
|
||||
id: number;
|
||||
name: string;
|
||||
character: string;
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
export interface Crew {
|
||||
id: number;
|
||||
name: string;
|
||||
job: string;
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
interface MovieResponse {
|
||||
page: number;
|
||||
results: Movie[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
interface TVShowResponse {
|
||||
page: number;
|
||||
results: TVShow[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export const moviesAPI = {
|
||||
// Получение популярных фильмов
|
||||
getPopular: (page = 1) =>
|
||||
api.get<MovieResponse>('/movie/popular', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение данных о фильме по его TMDB ID
|
||||
getMovie: (id: string | number) =>
|
||||
api.get<MovieDetails>(`/movie/${id}`, {
|
||||
params: {
|
||||
language: 'ru-RU',
|
||||
append_to_response: 'credits,videos,similar'
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение IMDb ID по TMDB ID для плеера
|
||||
getImdbId: async (tmdbId: string | number) => {
|
||||
try {
|
||||
const response = await api.get(`/movie/${tmdbId}/external_ids`);
|
||||
return response.data.imdb_id;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении IMDb ID:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Получение видео по TMDB ID для плеера
|
||||
getVideo: async (tmdbId: string | number) => {
|
||||
try {
|
||||
const response = await api.get(`/movie/${tmdbId}/videos`, {
|
||||
params: {
|
||||
language: 'ru-RU',
|
||||
},
|
||||
});
|
||||
return response.data.results;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении видео:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Поиск фильмов
|
||||
searchMovies: (query: string, page = 1) =>
|
||||
api.get<MovieResponse>('/search/movie', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение предстоящих фильмов
|
||||
getUpcoming: (page = 1) =>
|
||||
api.get('/movie/upcoming', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение лучших фильмов
|
||||
getTopRated: (page = 1) =>
|
||||
api.get('/movie/top_rated', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение фильмов по жанру
|
||||
getMoviesByGenre: (genreId: number, page = 1) =>
|
||||
api.get('/discover/movie', {
|
||||
params: {
|
||||
with_genres: genreId,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100,
|
||||
'vote_average.gte': 1,
|
||||
sort_by: 'popularity.desc',
|
||||
include_adult: false,
|
||||
'primary_release_date.lte': new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const tvAPI = {
|
||||
// Получение популярных сериалов
|
||||
getPopular: (page = 1) =>
|
||||
api.get<TVShowResponse>('/tv/popular', {
|
||||
params: {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение данных о сериале по его TMDB ID
|
||||
getShow: (id: string | number) =>
|
||||
api.get<TVShowDetails>(`/tv/${id}`, {
|
||||
params: {
|
||||
language: 'ru-RU',
|
||||
append_to_response: 'credits,external_ids',
|
||||
}
|
||||
}),
|
||||
|
||||
// Получение IMDb ID по TMDB ID для плеера
|
||||
getImdbId: (tmdbId: string | number) =>
|
||||
api.get<{ imdb_id: string | null }>(`/tv/${tmdbId}/external_ids`),
|
||||
|
||||
// Поиск сериалов
|
||||
searchShows: (query: string, page = 1) =>
|
||||
api.get<TVShowResponse>('/search/tv', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
// Мультипоиск (фильмы и сериалы)
|
||||
export const searchAPI = {
|
||||
multiSearch: (query: string, page = 1) =>
|
||||
api.get('/search/multi', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
}
|
||||
}),
|
||||
};
|
||||
89
src/lib/auth.ts
Normal file
89
src/lib/auth.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { AuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { User } from '@/models';
|
||||
import { connectDB } from './db';
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
id: 'credentials',
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error('Необходимо указать email и пароль');
|
||||
}
|
||||
|
||||
await connectDB();
|
||||
|
||||
const user = await User.findOne({ email: credentials.email });
|
||||
if (!user) {
|
||||
throw new Error('Пользователь не найден');
|
||||
}
|
||||
|
||||
const isValid = await user.comparePassword(credentials.password);
|
||||
if (!isValid) {
|
||||
throw new Error('Неверный пароль');
|
||||
}
|
||||
|
||||
if (!user.isVerified) {
|
||||
throw new Error('Email не подтвержден');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.isAdmin = user.isAdmin;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.email = token.email as string;
|
||||
session.user.isAdmin = token.isAdmin as boolean;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
},
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
};
|
||||
|
||||
// Расширяем типы для NextAuth
|
||||
declare module 'next-auth' {
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
38
src/lib/db.ts
Normal file
38
src/lib/db.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI!;
|
||||
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error('Please define the MONGODB_URI environment variable');
|
||||
}
|
||||
|
||||
let cached = global.mongoose;
|
||||
|
||||
if (!cached) {
|
||||
cached = global.mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
export async function connectDB() {
|
||||
if (cached.conn) {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
};
|
||||
|
||||
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
|
||||
return mongoose;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
cached.conn = await cached.promise;
|
||||
} catch (e) {
|
||||
cached.promise = null;
|
||||
throw e;
|
||||
}
|
||||
|
||||
return cached.conn;
|
||||
}
|
||||
11
src/lib/email.ts
Normal file
11
src/lib/email.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const sendVerificationEmail = async (email: string, token: string) => {
|
||||
// Заглушка для функции отправки email
|
||||
console.log(`Sending verification email to ${email} with token ${token}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendPasswordResetEmail = async (email: string, token: string) => {
|
||||
// Заглушка для функции отправки email
|
||||
console.log(`Sending password reset email to ${email} with token ${token}`);
|
||||
return true;
|
||||
};
|
||||
25
src/lib/jwt.ts
Normal file
25
src/lib/jwt.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function generateToken(payload: JWTPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): Promise<JWTPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) reject(err);
|
||||
resolve(decoded as JWTPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
44
src/lib/mailer.ts
Normal file
44
src/lib/mailer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.GMAIL_USER,
|
||||
pass: process.env.GMAIL_APP_PASSWORD, // Пароль приложения из Google Account
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendVerificationEmail(to: string, code: string) {
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.GMAIL_USER,
|
||||
to,
|
||||
subject: 'Подтверждение регистрации Neo Movies',
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Для завершения регистрации введите этот код:</p>
|
||||
<div style="
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
margin: 20px 0;
|
||||
">
|
||||
${code}
|
||||
</div>
|
||||
<p>Код действителен в течение 10 минут.</p>
|
||||
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return { error: 'Failed to send email' };
|
||||
}
|
||||
}
|
||||
30
src/lib/mongodb.ts
Normal file
30
src/lib/mongodb.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MongoClient } from 'mongodb';
|
||||
|
||||
if (!process.env.MONGODB_URI) {
|
||||
throw new Error('Please add your Mongo URI to .env');
|
||||
}
|
||||
|
||||
const uri = process.env.MONGODB_URI;
|
||||
let client: MongoClient;
|
||||
let clientPromise: Promise<MongoClient>;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
let globalWithMongo = global as typeof globalThis & {
|
||||
_mongoClientPromise?: Promise<MongoClient>;
|
||||
};
|
||||
|
||||
if (!globalWithMongo._mongoClientPromise) {
|
||||
client = new MongoClient(uri);
|
||||
globalWithMongo._mongoClientPromise = client.connect();
|
||||
}
|
||||
clientPromise = globalWithMongo._mongoClientPromise;
|
||||
} else {
|
||||
client = new MongoClient(uri);
|
||||
clientPromise = client.connect();
|
||||
}
|
||||
|
||||
export async function connectToDatabase() {
|
||||
const client = await clientPromise;
|
||||
const db = client.db();
|
||||
return { db, client };
|
||||
}
|
||||
26
src/lib/movieSync.ts
Normal file
26
src/lib/movieSync.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface Movie {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
posterUrl: string;
|
||||
year: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export const syncMovies = async (): Promise<Movie[]> => {
|
||||
// Заглушка для синхронизации фильмов
|
||||
console.log('Syncing movies...');
|
||||
return [];
|
||||
};
|
||||
|
||||
export const updateMovie = async (movie: Movie): Promise<Movie> => {
|
||||
// Заглушка для обновления фильма
|
||||
console.log(`Updating movie ${movie.title}`);
|
||||
return movie;
|
||||
};
|
||||
|
||||
export const deleteMovie = async (id: string): Promise<boolean> => {
|
||||
// Заглушка для удаления фильма
|
||||
console.log(`Deleting movie ${id}`);
|
||||
return true;
|
||||
};
|
||||
29
src/lib/registry.tsx
Normal file
29
src/lib/registry.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
|
||||
|
||||
export default function StyledComponentsRegistry({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
|
||||
|
||||
useServerInsertedHTML(() => {
|
||||
const styles = styledComponentsStyleSheet.getStyleElement();
|
||||
styledComponentsStyleSheet.instance.clearTag();
|
||||
return <>{styles}</>;
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
|
||||
{children}
|
||||
</StyleSheetManager>
|
||||
);
|
||||
}
|
||||
16
src/lib/utils.ts
Normal file
16
src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const generateVerificationToken = () => {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
};
|
||||
|
||||
export const validateEmail = (email: string) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
53
src/middleware.ts
Normal file
53
src/middleware.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { withAuth } from 'next-auth/middleware';
|
||||
import { NextRequestWithAuth } from 'next-auth/middleware';
|
||||
|
||||
export default withAuth(
|
||||
async function middleware(request: NextRequestWithAuth) {
|
||||
const token = await getToken({ req: request });
|
||||
const isAuth = !!token;
|
||||
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
|
||||
const isAdminPage = request.nextUrl.pathname.startsWith('/admin');
|
||||
const isAdminLoginPage = request.nextUrl.pathname === '/admin/login';
|
||||
|
||||
// Если это страница админ-панели
|
||||
if (isAdminPage) {
|
||||
// Если пользователь не авторизован
|
||||
if (!isAuth) {
|
||||
return NextResponse.redirect(new URL('/admin/login', request.url));
|
||||
}
|
||||
|
||||
// Если пользователь не админ и пытается зайти на админ-страницы (кроме логина)
|
||||
if (!token?.isAdmin && !isAdminLoginPage) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
// Если админ уже прошел верификацию и пытается зайти на страницу логина
|
||||
if (token?.isAdmin && token?.adminVerified && isAdminLoginPage) {
|
||||
return NextResponse.redirect(new URL('/admin', request.url));
|
||||
}
|
||||
|
||||
// Если админ не прошел верификацию и пытается зайти на админ-страницы (кроме логина)
|
||||
if (token?.isAdmin && !token?.adminVerified && !isAdminLoginPage) {
|
||||
return NextResponse.redirect(new URL('/admin/login', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Если авторизованный пользователь пытается зайти на страницу логина
|
||||
if (isAuthPage && isAuth) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
},
|
||||
{
|
||||
callbacks: {
|
||||
authorized: () => true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const config = {
|
||||
matcher: ['/login', '/admin/:path*'],
|
||||
};
|
||||
26
src/models/Movie.ts
Normal file
26
src/models/Movie.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface Movie {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
posterUrl: string;
|
||||
year: number;
|
||||
rating: number;
|
||||
isVisible?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const movieSchema = new mongoose.Schema({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
posterUrl: { type: String, required: true },
|
||||
year: { type: Number, required: true },
|
||||
rating: { type: Number, required: true },
|
||||
isVisible: { type: Boolean, default: true },
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default mongoose.models.Movie || mongoose.model('Movie', movieSchema);
|
||||
74
src/models/User.ts
Normal file
74
src/models/User.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Schema, model, models } from 'mongoose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export interface IUser {
|
||||
email: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
emailVerified?: Date;
|
||||
verificationToken?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUser>({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
image: String,
|
||||
emailVerified: Date,
|
||||
verificationToken: String,
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// Не включаем пароль в запросы по умолчанию
|
||||
userSchema.set('toJSON', {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.password;
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
|
||||
// Хэшируем пароль перед сохранением
|
||||
userSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password!, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error as Error);
|
||||
}
|
||||
});
|
||||
|
||||
// Метод для проверки пароля
|
||||
userSchema.methods.comparePassword = async function(candidatePassword: string) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const User = models.User || model('User', userSchema);
|
||||
export default User;
|
||||
6
src/models/index.ts
Normal file
6
src/models/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import User from './User';
|
||||
import Movie from './Movie';
|
||||
|
||||
export { User, Movie };
|
||||
export type { IUser } from './User';
|
||||
export type { Movie as MovieType } from './Movie';
|
||||
7
src/providers/AuthProvider.tsx
Normal file
7
src/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
22
src/styles/GlobalStyles.ts
Normal file
22
src/styles/GlobalStyles.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
/* Отключаем стили Dark Reader для определенных элементов */
|
||||
*[data-darkreader-mode],
|
||||
*[data-darkreader-scheme] {
|
||||
background-color: unset !important;
|
||||
color: unset !important;
|
||||
}
|
||||
|
||||
/* Сбрасываем инлайн-стили Dark Reader */
|
||||
*[style*="--darkreader-inline"] {
|
||||
background-color: unset !important;
|
||||
color: unset !important;
|
||||
border-color: unset !important;
|
||||
fill: unset !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default GlobalStyles;
|
||||
69
src/styles/GlobalStyles.tsx
Normal file
69
src/styles/GlobalStyles.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background-color: ${({ theme }) => theme.colors.background};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#__next {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Скрываем уведомления об ошибках Next.js */
|
||||
.nextjs-toast-errors-parent {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Скрываем все toast-уведомления Next.js */
|
||||
div[id^='__next-build-watcher'],
|
||||
div[class^='nextjs-toast'] {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
24
src/types/auth.ts
Normal file
24
src/types/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface User {
|
||||
_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isVerified: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
15
src/types/movie.ts
Normal file
15
src/types/movie.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface Movie {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
year: number;
|
||||
rating: number;
|
||||
posterUrl: string;
|
||||
genres: string[];
|
||||
director: string;
|
||||
cast: string[];
|
||||
duration: number;
|
||||
trailerUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user