Update 103 files

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

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# 🎬 Neo Movies
<div align="center">
<img src="public/logo.png" alt="Neo Movies Logo" width="200"/>
<p><strong>Современный онлайн-кинотеатр с удобным интерфейсом</strong></p>
</div>
## 📋 О проекте
Neo Movies - это современная веб-платформа для просмотра фильмов, построенная с использованием передовых технологий. Проект предлагает удобный интерфейс, быструю навигацию и множество функций для комфортного просмотра фильмов.
### ✨ Основные возможности
- 🎥 Три встроенных видеоплеера на выбор (Alloha, Collaps, Lumex)
- 🔍 Умный поиск по фильмам
- 📱 Адаптивный дизайн для всех устройств
- 🌙 Темная тема
- 👤 Система авторизации и профили пользователей
- ❤️ Возможность добавлять фильмы в избранное
- ⚡ Быстрая загрузка и оптимизированная производительность
## 🛠 Технологии
- **Frontend:**
- Next.js 13+ (App Router)
- React 18
- TypeScript
- Styled Components
- NextAuth.js
- **Backend:**
- Next.js
- MongoDB
- Mongoose
- **Дополнительно:**
- ESLint
- Prettier
- Git
- npm
## Установка
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/your-username/neo-movies-web.git
cd neo-movies-web
```
2. **Установите зависимости:**
```bash
npm install
```
3. **Создайте файл `.env` в корневой директории и добавьте следующие переменные:**
```env
# База данных MongoDB
MONGODB_URI=your_mongodb_uri
# NextAuth конфигурация
NEXTAUTH_SECRET=your_nextauth_secret
NEXTAUTH_URL=http://localhost:3000
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email конфигурация (для подтверждения регистрации)
GMAIL_USER=your_gmail@gmail.com
GMAIL_APP_PASSWORD=your_app_specific_password
# TMDB API (для получения информации о фильмах)
NEXT_PUBLIC_TMDB_API_KEY=your_tmdb_api_key
NEXT_PUBLIC_TMDB_ACCESS_TOKEN=your_tmdb_access_token
# JWT конфигурация
JWT_SECRET=your_jwt_secret
# Lumex Player URL
NEXT_PUBLIC_LUMEX_URL=your_lumex_player_url
```
4. **Запустите проект:**
```bash
# Режим разработки
npm run dev
# Сборка для продакшена
npm run build
npm start
```
## Получение API ключей
### TMDB API
1. Создайте аккаунт на [TMDB](https://www.themoviedb.org/)
2. Перейдите в настройки профиля -> API
3. Создайте новое API приложение
4. Скопируйте API ключ и Access Token
### Google OAuth
1. Перейдите в [Google Cloud Console](https://console.cloud.google.com/)
2. Создайте новый проект
3. Включите Google OAuth API
4. Создайте учетные данные OAuth 2.0
5. Добавьте разрешенные URI перенаправления:
- http://localhost:3000/api/auth/callback/google
- https://your-domain.com/api/auth/callback/google
### Gmail App Password
1. Включите двухфакторную аутентификацию в аккаунте Google
2. Перейдите в настройки безопасности
3. Создайте пароль приложения
4. Используйте этот пароль в GMAIL_APP_PASSWORD
## Разработка
### Структура проекта
```
neo-movies-web/
├── src/
│ ├── app/ # App Router pages
│ ├── components/ # React компоненты
│ ├── hooks/ # React хуки
│ ├── lib/ # Утилиты и API
│ ├── models/ # MongoDB модели
│ └── styles/ # Глобальные стили
├── public/ # Статические файлы
└── package.json
```
## 👥 Авторы
- **Frontend Developer** - [Foxix](https://gitlab.com/fenixoffc1)
## 📄 Лицензия
Этот проект распространяется под лицензией MIT. Подробности в файле [LICENSE](LICENSE).
## 🤝 Участие в проекте
Мы приветствуем любой вклад в развитие проекта! Если у вас есть предложения по улучшению:
1. Форкните репозиторий
2. Создайте ветку для ваших изменений
3. Внесите изменения
4. Отправьте pull request
## 📞 Контакты
Если у вас возникли вопросы или предложения, свяжитесь с нами:
- Email: neo.movies.mail@gmail.com
- Telegram: @foxix_us
---
<div align="center">
<p>Made with ❤️ by Foxix</p>
</div>

22
middleware.ts Normal file
View File

@@ -0,0 +1,22 @@
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
const isAuthPage =
request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/verify');
// Если пользователь авторизован и пытается зайти на страницу авторизации
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
// Указываем, для каких путей должен срабатывать middleware
export const config = {
matcher: ['/login', '/verify']
};

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

38
next.config.js Normal file
View File

@@ -0,0 +1,38 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
compiler: {
styledComponents: true,
},
devIndicators: {
appIsrStatus: false,
buildActivity: false,
buildActivityPosition: 'bottom-right',
},
env: {
MONGODB_URI: process.env.MONGODB_URI,
JWT_SECRET: process.env.JWT_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'image.tmdb.org',
pathname: '/**',
},
],
},
onDemandEntries: {
maxInactiveAge: 25 * 1000,
pagesBufferLength: 2,
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
scrollRestoration: true,
},
};
module.exports = nextConfig;

7658
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "neo-movies-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@reduxjs/toolkit": "^2.5.0",
"@tabler/icons-react": "^3.26.0",
"@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.13",
"@types/styled-components": "^5.1.34",
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mongodb": "^6.12.0",
"mongoose": "^8.9.2",
"next": "15.1.2",
"next-auth": "^4.24.11",
"nodemailer": "^6.9.16",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"react-redux": "^9.2.0",
"resend": "^4.0.1",
"styled-components": "^6.1.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.2",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

9
public/google.svg Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 Z" fill="#4285F4"></path>
<path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 Z" fill="#34A853"></path>
<path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 Z" fill="#FBBC05"></path>
<path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 Z" fill="#EA4335"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

153
src/api.ts Normal file
View 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 [];
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,32 @@
'use client';
import { ThemeProvider } from 'styled-components';
import { SessionProvider } from 'next-auth/react';
const theme = {
colors: {
primary: '#2196f3',
background: '#121212',
surface: '#1e1e1e',
text: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.7)',
error: '#f44336',
success: '#4caf50',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
};
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<SessionProvider refetchInterval={0} refetchOnWindowFocus={false}>
{children}
</SessionProvider>
</ThemeProvider>
);
}

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

@@ -0,0 +1,119 @@
'use client';
import { GlassCard } from '@/components/GlassCard';
import { VerificationClient } from './VerificationClient';
import styled from 'styled-components';
import { useSearchParams, useRouter } from 'next/navigation';
import { useEffect, Suspense } from 'react';
const Container = styled.div`
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background-color: #0a0a0a;
overflow: hidden;
`;
const Content = styled.main`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 1200px;
padding: 2rem;
position: relative;
z-index: 1;
`;
const GlowingBackground = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 0;
`;
const Glow = styled.div`
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.3;
animation: float 20s infinite ease-in-out;
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-30px, 30px); }
}
`;
const Glow1 = styled(Glow)`
background: #2196f3;
width: 600px;
height: 600px;
top: -200px;
left: -200px;
animation-delay: 0s;
`;
const Glow2 = styled(Glow)`
background: #9c27b0;
width: 500px;
height: 500px;
bottom: -150px;
right: -150px;
animation-delay: -5s;
`;
const Glow3 = styled(Glow)`
background: #00bcd4;
width: 400px;
height: 400px;
bottom: 100px;
left: 30%;
animation-delay: -10s;
`;
function VerifyContent() {
const searchParams = useSearchParams();
const router = useRouter();
const email = searchParams.get('email');
useEffect(() => {
if (!email) {
router.push('/login');
}
}, [email, router]);
if (!email) {
return null;
}
return (
<Container>
<GlowingBackground>
<Glow1 />
<Glow2 />
<Glow3 />
</GlowingBackground>
<Content>
<GlassCard>
<VerificationClient email={email} />
</GlassCard>
</Content>
</Container>
);
}
export default function VerificationPage() {
return (
<Suspense>
<VerifyContent />
</Suspense>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;

View 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;

View 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>
);

View 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>
);

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
`;

View 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
View 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)} />
)}
</>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import GlobalStyles from '@/styles/GlobalStyles';
export default function StyleProvider({ children }: { children: React.ReactNode }) {
return (
<>
<GlobalStyles />
{children}
</>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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';

View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View 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;

View 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
View 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
View 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;
}

Some files were not shown because too many files have changed in this diff Show More