Update 35 files

- /src/api.ts
- /src/lib/utils.ts
- /src/lib/neoApi.ts
- /src/lib/mongodb.ts
- /src/lib/favoritesApi.ts
- /src/lib/models/Favorite.ts
- /src/hooks/useTMDBMovies.ts
- /src/hooks/useImageLoader.ts
- /src/hooks/useMovies.ts
- /src/types/movie.ts
- /src/components/SearchResults.tsx
- /src/components/SettingsContent.tsx
- /src/components/MovieCard.tsx
- /src/components/FavoriteButton.tsx
- /src/components/admin/MovieSearch.tsx
- /src/app/page.tsx
- /src/app/movie/[id]/page.tsx
- /src/app/movie/[id]/MovieContent.tsx
- /src/app/api/movies/upcoming/route.ts
- /src/app/api/movies/search/route.ts
- /src/app/api/movies/top-rated/route.ts
- /src/app/api/movies/[id]/route.ts
- /src/app/api/movies/popular/route.ts
- /src/app/api/favorites/route.ts
- /src/app/api/favorites/check/[mediaId]/route.ts
- /src/app/api/favorites/[mediaId]/route.ts
- /src/app/tv/[id]/TVShowContent.tsx
- /src/app/tv/[id]/TVShowPage.tsx
- /src/app/tv/[id]/page.tsx
- /src/app/favorites/page.tsx
- /src/configs/auth.ts
- /next.config.js
- /package.json
- /README.md
- /package-lock.json
This commit is contained in:
2025-01-05 01:43:34 +00:00
parent 3c3f58c7d3
commit 0aa6fb6038
35 changed files with 1656 additions and 548 deletions

135
README.md
View File

@@ -39,94 +39,49 @@ Neo Movies - это современная веб-платформа для пр
- Git - Git
- npm - npm
## Установка ## Начало работы
1. **Клонируйте репозиторий:** 1. Клонируйте репозиторий:
```bash ```bash
git clone https://gitlab.com/foxixus/neomovies.git git clone https://gitlab.com/foxixus/neomovies.git
cd neomovies cd neomovies
```
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/ 2. Установите зависимости:
│ ├── app/ # App Router pages ```bash
│ ├── components/ # React компоненты npm install
│ ├── hooks/ # React хуки ```
│ ├── lib/ # Утилиты и API
│ ├── models/ # MongoDB модели 3. Создайте файл `.env` и добавьте следующие переменные:
│ └── styles/ # Глобальные стили ```env
├── public/ # Статические файлы NEXT_PUBLIC_API_URL=https://neomovies-api.vercel.app
└── package.json ```
4. Запустите приложение:
```bash
npm run dev
```
Приложение будет доступно по адресу [http://localhost:3000](http://localhost:3000)
## API
Приложение использует отдельный API сервер. API предоставляет следующие возможности:
- Поиск фильмов и сериалов
- Получение детальной информации о фильме/сериале
- Оптимизированная загрузка изображений
- Кэширование запросов
## Структура проекта
```
src/
├── app/ # App Router и страницы
├── components/ # React компоненты
├── lib/ # Утилиты и API клиенты
├── types/ # TypeScript типы
└── utils/ # Вспомогательные функции
``` ```
## 👥 Авторы ## 👥 Авторы
@@ -135,7 +90,7 @@ neo-movies-web/
## 📄 Лицензия ## 📄 Лицензия
Этот проект распространяется под лицензией MIT. Подробности в файле [LICENSE](LICENSE). Этот проект распространяется под лицензией Apache-2.0. Подробности в файле [LICENSE](LICENSE).
## 🤝 Участие в проекте ## 🤝 Участие в проекте
@@ -146,6 +101,12 @@ neo-movies-web/
3. Внесите изменения 3. Внесите изменения
4. Отправьте pull request 4. Отправьте pull request
## Благодарности
- [TMDB](https://www.themoviedb.org/) за предоставление API
- [Vercel](https://vercel.com/) за хостинг API
## 📞 Контакты ## 📞 Контакты
Если у вас возникли вопросы или предложения, свяжитесь с нами: Если у вас возникли вопросы или предложения, свяжитесь с нами:

View File

@@ -21,6 +21,12 @@ const nextConfig = {
hostname: 'image.tmdb.org', hostname: 'image.tmdb.org',
pathname: '/**', pathname: '/**',
}, },
{
protocol: 'http',
hostname: 'localhost',
port: '3010',
pathname: '/images/**',
}
], ],
}, },
onDemandEntries: { onDemandEntries: {

10
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"next": "15.1.2", "next": "15.1.2",
@@ -4824,6 +4825,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide-react": {
"version": "0.469.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
"integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/make-dir": { "node_modules/make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",

View File

@@ -22,6 +22,7 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"next": "15.1.2", "next": "15.1.2",

View File

@@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
const BASE_URL = 'https://api.themoviedb.org/3'; const BASE_URL = '/api/movies';
if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) { if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN) {
throw new Error('TMDB_ACCESS_TOKEN is not defined in environment variables'); throw new Error('TMDB_ACCESS_TOKEN is not defined in environment variables');
@@ -9,7 +9,6 @@ if (typeof window === 'undefined' && !process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN)
export const api = axios.create({ export const api = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
headers: { headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
@@ -20,7 +19,21 @@ export interface MovieDetails extends Movie {
tagline: string; tagline: string;
budget: number; budget: number;
revenue: number; revenue: number;
videos: { videos: {{
"page": 1,
"results": [
{
"id": 123,
"title": "Movie Title",
"overview": "Movie description",
"poster_path": "/path/to/poster.jpg",
...
},
...
],
"total_pages": 500,
"total_results": 10000
}
results: Video[]; results: Video[];
}; };
credits: { credits: {
@@ -53,101 +66,32 @@ export interface Crew {
export const moviesAPI = { export const moviesAPI = {
// Получение популярных фильмов // Получение популярных фильмов
getPopular: (page = 1) => async getPopular(page = 1) {
api.get('/discover/movie', { const response = await api.get(`/popular?page=${page}`);
params: { return response.data;
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) => { async getMovie(id: string | number) {
try { const response = await api.get(`/${id}`);
const response = await api.get(`/movie/${tmdbId}/videos`, { return response.data;
params: { },
language: 'en-US', // Язык для видео
}, // Поиск фильмов
}); async searchMovies(query: string, page = 1) {
return response.data.results; const response = await api.get(`/search?query=${encodeURIComponent(query)}&page=${page}`);
} catch (error) { return response.data;
console.error('Ошибка при получении видео:', error); },
return [];
} // Получение предстоящих фильмов
async getUpcoming(page = 1) {
const response = await api.get(`/upcoming?page=${page}`);
return response.data;
},
// Получение топ рейтинговых фильмов
async getTopRated(page = 1) {
const response = await api.get(`/top-rated?page=${page}`);
return response.data;
} }
}; };

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { connectToDatabase } from '@/lib/mongodb';
// DELETE /api/favorites/[mediaId] - удалить из избранного
export async function DELETE(
request: Request,
{ params }: { params: { mediaId: string } }
) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const mediaType = searchParams.get('mediaType');
const mediaId = params.mediaId;
if (!mediaType || !mediaId) {
return NextResponse.json({ error: 'Missing mediaType or mediaId' }, { status: 400 });
}
const { db } = await connectToDatabase();
const result = await db.collection('favorites').deleteOne({
userId: session.user.email,
mediaId,
mediaType
});
if (result.deletedCount === 0) {
return NextResponse.json({ error: 'Favorite not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Removed from favorites' });
} catch (error) {
console.error('Error removing from favorites:', error);
return NextResponse.json({ error: 'Failed to remove from favorites' }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { connectToDatabase } from '@/lib/mongodb';
// GET /api/favorites/check/[mediaId] - проверить есть ли в избранном
export async function GET(
request: Request,
{ params }: { params: { mediaId: string } }
) {
try {
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const mediaType = searchParams.get('mediaType');
const { db } = await connectToDatabase();
const favorite = await db.collection('favorites').findOne({
userId: session.user.email,
mediaId: params.mediaId,
mediaType
});
return NextResponse.json({ isFavorite: !!favorite });
} catch (error) {
console.error('Error checking favorite:', error);
return NextResponse.json({ error: 'Failed to check favorite status' }, { status: 500 });
}
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { connectToDatabase, resetIndexes } from '@/lib/mongodb';
// Флаг для отслеживания инициализации
let isInitialized = false;
// GET /api/favorites - получить все избранные
export async function GET() {
try {
// Инициализируем индексы при первом запросе
if (!isInitialized) {
await resetIndexes();
isInitialized = true;
}
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { db } = await connectToDatabase();
const favorites = await db.collection('favorites')
.find({ userId: session.user.email })
.sort({ createdAt: -1 })
.toArray();
return NextResponse.json(favorites);
} catch (error) {
console.error('Error getting favorites:', error);
return NextResponse.json({ error: 'Failed to get favorites' }, { status: 500 });
}
}
// POST /api/favorites - добавить в избранное
export async function POST(request: Request) {
try {
// Инициализируем индексы при первом запросе
if (!isInitialized) {
await resetIndexes();
isInitialized = true;
}
const session = await getServerSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { mediaId, mediaType, title, posterPath } = await request.json();
if (!mediaId || !mediaType || !title) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { db } = await connectToDatabase();
const favorite = {
userId: session.user.email,
mediaId: mediaId.toString(), // Преобразуем в строку для консистентности
mediaType,
title,
posterPath,
createdAt: new Date()
};
// Используем updateOne с upsert вместо insertOne
const result = await db.collection('favorites').updateOne(
{
userId: session.user.email,
mediaId: favorite.mediaId,
mediaType
},
{ $set: favorite },
{ upsert: true }
);
// Если документ был обновлен (уже существовал)
if (result.matchedCount > 0) {
return NextResponse.json({ message: 'Already in favorites' }, { status: 200 });
}
// Если документ был создан (новый)
return NextResponse.json(favorite);
} catch (error) {
console.error('Error adding to favorites:', error);
return NextResponse.json({ error: 'Failed to add to favorites' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.themoviedb.org/3',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
});
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const id = params.id;
try {
const response = await api.get(`/movie/${id}`, {
params: {
language: 'ru-RU',
append_to_response: 'credits,videos,similar'
}
});
return NextResponse.json(response.data);
} catch (error: any) {
console.error('Error fetching movie details:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch movie details' },
{ status: error.response?.status || 500 }
);
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.themoviedb.org/3',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_TMDB_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
}
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
try {
const response = await 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]
}
});
return NextResponse.json(response.data);
} catch (error: any) {
console.error('Error fetching popular movies:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch movies' },
{ status: error.response?.status || 500 }
);
}
}

View File

@@ -1,8 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { searchAPI } from '@/lib/api'; import { searchAPI } from '@/lib/neoApi';
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_API_URL = 'https://api.themoviedb.org/3';
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -16,12 +13,15 @@ export async function GET(request: Request) {
} }
try { try {
const { data } = await searchAPI.multiSearch(query); const response = await searchAPI.multiSearch(query);
return NextResponse.json(data); return NextResponse.json(response.data);
} catch (error) { } catch (error: any) {
console.error('Error searching:', error); console.error('Error searching:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to search' }, {
error: 'Failed to search',
details: error.message
},
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const response = await fetch(
`https://api.themoviedb.org/3/movie/top_rated?page=${page}`,
{
headers: {
'Authorization': `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
return NextResponse.json(data);
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const response = await fetch(
`https://api.themoviedb.org/3/movie/upcoming?page=${page}`,
{
headers: {
'Authorization': `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
return NextResponse.json(data);
}

165
src/app/favorites/page.tsx Normal file
View File

@@ -0,0 +1,165 @@
'use client';
import { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import Image from 'next/image';
import { useSession } from 'next-auth/react';
import { favoritesAPI } from '@/lib/favoritesApi';
import { getImageUrl } from '@/lib/neoApi';
const Container = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
`;
const Title = styled.h1`
font-size: 2rem;
color: white;
margin-bottom: 2rem;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
`;
const Card = styled(Link)`
position: relative;
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
text-decoration: none;
&:hover {
transform: translateY(-5px);
}
`;
const Poster = styled.div`
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
`;
const Info = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
`;
const MediaTitle = styled.h2`
font-size: 1rem;
color: white;
margin: 0;
`;
const MediaType = styled.span`
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
`;
const EmptyState = styled.div`
text-align: center;
color: rgba(255, 255, 255, 0.7);
padding: 4rem 0;
`;
interface Favorite {
id: number;
mediaId: string;
mediaType: 'movie' | 'tv';
title: string;
posterPath: string;
}
export default function FavoritesPage() {
const { data: session } = useSession();
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFavorites = async () => {
if (!session?.user) {
setLoading(false);
return;
}
try {
const response = await favoritesAPI.getFavorites();
setFavorites(response.data);
} catch (error) {
console.error('Error fetching favorites:', error);
} finally {
setLoading(false);
}
};
fetchFavorites();
}, [session?.user]);
if (loading) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>Загрузка...</EmptyState>
</Container>
);
}
if (!session?.user) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>
Для доступа к избранному необходимо авторизоваться
</EmptyState>
</Container>
);
}
if (favorites.length === 0) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>
У вас пока нет избранных фильмов и сериалов
</EmptyState>
</Container>
);
}
return (
<Container>
<Title>Избранное</Title>
<Grid>
{favorites.map(favorite => (
<Card
key={`${favorite.mediaType}-${favorite.mediaId}`}
href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`}
>
<Poster>
<Image
src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/placeholder.jpg'}
alt={favorite.title}
width={200}
height={300}
className="rounded-lg"
/>
</Poster>
<Info>
<MediaTitle>{favorite.title}</MediaTitle>
<MediaType>{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}</MediaType>
</Info>
</Card>
))}
</Grid>
</Container>
);
}

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useEffect, useState, Suspense } from 'react'; import { useState, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { moviesAPI } from '@/lib/api'; import { moviesAPI } from '@/lib/api';
import { getImageUrl } from '@/lib/neoApi';
import type { MovieDetails } from '@/lib/api'; import type { MovieDetails } from '@/lib/api';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import MoviePlayer from '@/components/MoviePlayer'; import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
declare global { declare global {
interface Window { interface Window {
@@ -120,56 +122,39 @@ const ErrorContainer = styled.div`
color: #ff4444; color: #ff4444;
`; `;
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
export default function MovieContent() { interface MovieContentProps {
const { id: movieId } = useParams(); movieId: string;
initialMovie: MovieDetails;
}
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
const { settings } = useSettings(); const { settings } = useSettings();
const [loading, setLoading] = useState(true); const [movie] = useState<MovieDetails>(initialMovie);
const [error, setError] = useState<string | null>(null);
const [movie, setMovie] = useState<MovieDetails | null>(null);
const [imdbId, setImdbId] = useState<string | null>(null); const [imdbId, setImdbId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchMovie = async () => { const fetchImdbId = async () => {
if (!movieId) return;
try { try {
setLoading(true);
const response = await moviesAPI.getMovie(movieId);
setMovie(response.data);
const newImdbId = await moviesAPI.getImdbId(movieId); const newImdbId = await moviesAPI.getImdbId(movieId);
if (!newImdbId) { if (newImdbId) {
setError('IMDb ID не найден'); setImdbId(newImdbId);
return;
} }
setImdbId(newImdbId);
setError(null);
} catch (err) { } catch (err) {
console.error('Error fetching movie:', err); console.error('Error fetching IMDb ID:', err);
setError('Ошибка при загрузке фильма');
} finally {
setLoading(false);
} }
}; };
fetchImdbId();
fetchMovie();
}, [movieId]); }, [movieId]);
if (loading) return <LoadingContainer>Загрузка...</LoadingContainer>;
if (error) return <ErrorContainer>{error}</ErrorContainer>;
if (!movie || !imdbId) return null;
return ( return (
<Container> <Container>
<Content> <Content>
<MovieInfo> <MovieInfo>
<PosterContainer> <PosterContainer>
<Poster <Poster
src={movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '/placeholder.jpg'} src={getImageUrl(movie.poster_path)}
alt={movie.title} alt={movie.title}
/> />
</PosterContainer> </PosterContainer>
@@ -188,19 +173,24 @@ export default function MovieContent() {
</GenreList> </GenreList>
{movie.tagline && <Tagline>{movie.tagline}</Tagline>} {movie.tagline && <Tagline>{movie.tagline}</Tagline>}
<Overview>{movie.overview}</Overview> <Overview>{movie.overview}</Overview>
<div style={{ marginTop: '1rem' }}>
<FavoriteButton
mediaId={movie.id.toString()}
mediaType="movie"
title={movie.title}
posterPath={movie.poster_path}
/>
</div>
</Details> </Details>
</MovieInfo> </MovieInfo>
<PlayerSection> {imdbId && (
<Suspense fallback={<LoadingContainer>Загрузка плеера...</LoadingContainer>}> <PlayerSection>
<MoviePlayer <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} imdbId={imdbId}
/> />
</Suspense> </PlayerSection>
</PlayerSection> )}
</Content> </Content>
</Container> </Container>
); );

View File

@@ -1,5 +1,6 @@
import MoviePage from './MoviePage'; import { Metadata } from 'next';
import { moviesAPI } from '@/lib/api'; import { moviesAPI } from '@/lib/api';
import MoviePage from '@/app/movie/[id]/MoviePage';
interface PageProps { interface PageProps {
params: { params: {
@@ -7,17 +8,34 @@ interface PageProps {
}; };
} }
// Генерация метаданных для страницы
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = params;
try {
const { data: movie } = await moviesAPI.getMovie(id);
return {
title: `${movie.title} - NeoMovies`,
description: movie.overview,
};
} catch (error) {
return {
title: 'Фильм - NeoMovies',
};
}
}
// Получение данных для страницы
async function getData(id: string) { async function getData(id: string) {
try { try {
const response = await moviesAPI.getMovie(id); const { data: movie } = await moviesAPI.getMovie(id);
return { id, movie: response.data }; return { id, movie };
} catch (error) { } catch (error) {
console.error('Error fetching movie:', error); throw new Error('Failed to fetch movie');
return { id, movie: null };
} }
} }
export default async function Page({ params }: PageProps) { export default async function Page({ params }: PageProps) {
const data = await getData(params.id); const { id } = params;
const data = await getData(id);
return <MoviePage movieId={data.id} movie={data.movie} />; return <MoviePage movieId={data.id} movie={data.movie} />;
} }

View File

@@ -7,6 +7,8 @@ import { HeartIcon } from '@/components/Icons/HeartIcon';
import MovieCard from '@/components/MovieCard'; import MovieCard from '@/components/MovieCard';
import { useMovies } from '@/hooks/useMovies'; import { useMovies } from '@/hooks/useMovies';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import { getImageUrl } from '@/lib/neoApi';
import FavoriteButton from '@/components/FavoriteButton';
const Container = styled.div` const Container = styled.div`
min-height: 100vh; min-height: 100vh;
@@ -19,15 +21,26 @@ const Container = styled.div`
} }
`; `;
const FeaturedMovie = styled.div` const FeaturedMovie = styled.div<{ $backdrop: string }>`
position: relative; position: relative;
width: 100%; width: 100%;
height: 600px; height: 600px;
background-image: ${props => `url(${props.$backdrop})`};
background-size: cover; background-size: cover;
background-position: center; background-position: center;
margin-bottom: 2rem; margin-bottom: 2rem;
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.8) 100%);
}
`; `;
const Overlay = styled.div` const Overlay = styled.div`
@@ -36,14 +49,16 @@ const Overlay = styled.div`
left: 0; left: 0;
right: 0; right: 0;
bottom: 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; display: flex;
align-items: center; align-items: center;
padding: 2rem; padding: 2rem;
`; `;
const FeaturedContent = styled.div` const FeaturedContent = styled.div`
max-width: 600px; position: relative;
z-index: 1;
max-width: 800px;
padding: 2rem;
color: white; color: white;
`; `;
@@ -64,49 +79,32 @@ const Title = styled.h1`
font-size: 3rem; font-size: 3rem;
font-weight: bold; font-weight: bold;
margin-bottom: 1rem; margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
`; `;
const Description = styled.p` const Overview = styled.p`
font-size: 1.125rem; font-size: 1.125rem;
margin-bottom: 2rem; margin-bottom: 2rem;
opacity: 0.9; opacity: 0.9;
line-height: 1.6; line-height: 1.6;
`; `;
const ButtonGroup = styled.div` const ButtonContainer = styled.div`
display: flex; display: flex;
gap: 1rem; gap: 1rem;
`; `;
const WatchButton = styled.div` const WatchButton = styled.button`
background: ${props => props.theme.colors.primary};
color: white;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
background: #e50914;
color: white;
border: none; border: none;
border-radius: 9999px; border-radius: 0.5rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover { &:hover {
background: #2563eb; background: #f40612;
}
svg {
width: 20px;
height: 20px;
}
`;
const FavoriteButton = styled(WatchButton)`
background: rgba(255, 255, 255, 0.1);
&:hover {
background: rgba(255, 255, 255, 0.2);
} }
`; `;
@@ -144,11 +142,7 @@ export default function HomePage() {
return ( return (
<Container> <Container>
{featuredMovie && ( {featuredMovie && (
<FeaturedMovie <FeaturedMovie $backdrop={getImageUrl(featuredMovie.backdrop_path, 'original')}>
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/original${featuredMovie.backdrop_path})`,
}}
>
<Overlay> <Overlay>
<FeaturedContent> <FeaturedContent>
<GenreTags> <GenreTags>
@@ -157,21 +151,18 @@ export default function HomePage() {
))} ))}
</GenreTags> </GenreTags>
<Title>{featuredMovie.title}</Title> <Title>{featuredMovie.title}</Title>
<Description>{featuredMovie.overview}</Description> <Overview>{featuredMovie.overview}</Overview>
<ButtonGroup> <ButtonContainer>
<Link href={`/movie/${featuredMovie.id}`}> <Link href={`/movie/${featuredMovie.id}`}>
<WatchButton> <WatchButton>Смотреть</WatchButton>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
Смотреть
</WatchButton>
</Link> </Link>
<FavoriteButton as="button"> <FavoriteButton
<HeartIcon /> mediaId={featuredMovie.id.toString()}
В избранное mediaType="movie"
</FavoriteButton> title={featuredMovie.title}
</ButtonGroup> posterPath={featuredMovie.poster_path}
/>
</ButtonContainer>
</FeaturedContent> </FeaturedContent>
</Overlay> </Overlay>
</FeaturedMovie> </FeaturedMovie>

View File

@@ -1,10 +1,10 @@
'use client'; import { useState, useEffect } from 'react';
import { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Image from 'next/image'; import Image from 'next/image';
import type { TVShowDetails } from '@/lib/api'; import type { TVShow } from '@/types/movie';
import { tvShowsAPI, getImageUrl } from '@/lib/neoApi';
import MoviePlayer from '@/components/MoviePlayer'; import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
@@ -12,17 +12,6 @@ const Container = styled.div`
margin: 0 auto; 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` const PosterContainer = styled.div`
position: relative; position: relative;
width: 100%; width: 100%;
@@ -31,47 +20,42 @@ const PosterContainer = styled.div`
overflow: hidden; overflow: hidden;
`; `;
const InfoContent = styled.div` const Info = styled.div`
color: white; color: white;
`; `;
const Title = styled.h1` const Title = styled.h1`
font-size: 2.5rem; font-size: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
color: white;
`; `;
const Overview = styled.p` const Overview = styled.p`
margin-bottom: 1.5rem; color: rgba(255, 255, 255, 0.8);
line-height: 1.6; 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; margin-bottom: 1rem;
color: white;
padding-top: 1rem;
`; `;
const PlayerSection = styled(Section)` const Details = styled.div`
margin-top: 2rem; flex: 1;
min-height: 500px; `;
const DetailItem = styled.div`
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.8);
`;
const Label = styled.span`
color: rgba(255, 255, 255, 0.6);
margin-right: 0.5rem;
`;
const Value = styled.span`
color: white;
`;
const ButtonContainer = styled.div`
margin-top: 1rem;
`; `;
const PlayerContainer = styled.div` const PlayerContainer = styled.div`
@@ -83,133 +67,121 @@ const PlayerContainer = styled.div`
overflow: hidden; overflow: hidden;
`; `;
const CastGrid = styled.div` const Content = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: 300px 1fr;
gap: 1rem; gap: 2rem;
margin-top: 1rem; margin-bottom: 2rem;
`;
const CastCard = styled.div` @media (max-width: 768px) {
background: rgba(255, 255, 255, 0.1); grid-template-columns: 1fr;
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
} }
`; `;
const CastImageContainer = styled.div` const parseRussianDate = (dateStr: string): Date | null => {
position: relative; if (!dateStr) return null;
width: 100%;
height: 225px;
`;
const CastInfo = styled.div` const months: { [key: string]: number } = {
padding: 0.75rem; 'января': 0, 'февраля': 1, 'марта': 2, 'апреля': 3,
`; 'мая': 4, 'июня': 5, 'июля': 6, 'августа': 7,
'сентября': 8, 'октября': 9, 'ноября': 10, 'декабря': 11
};
const CastName = styled.h3` const match = dateStr.match(/(\d+)\s+([а-яё]+)\s+(\d{4})/i);
font-size: 0.9rem; if (!match) return null;
margin-bottom: 0.25rem;
color: white;
`;
const Character = styled.p` const [, day, month, year] = match;
font-size: 0.8rem; const monthIndex = months[month.toLowerCase()];
color: rgba(255, 255, 255, 0.6);
`; if (monthIndex === undefined) return null;
return new Date(parseInt(year), monthIndex, parseInt(day));
};
interface TVShowContentProps { interface TVShowContentProps {
tvShowId: string; tvShowId: string;
initialShow: TVShowDetails; initialShow: TVShow;
} }
export default function TVShowContent({ tvShowId, initialShow }: TVShowContentProps) { export default function TVShowContent({ tvShowId, initialShow }: TVShowContentProps) {
const [show] = useState<TVShowDetails>(initialShow); const [show] = useState<TVShow>(initialShow);
const [imdbId, setImdbId] = useState<string | null>(null);
const formatDate = (date: string) => { useEffect(() => {
return new Date(date).toLocaleDateString('ru-RU', { const fetchImdbId = async () => {
year: 'numeric', try {
month: 'long', const newImdbId = await tvShowsAPI.getImdbId(tvShowId);
day: 'numeric', if (newImdbId) {
}); setImdbId(newImdbId);
}; }
} catch (err) {
console.error('Error fetching IMDb ID:', err);
}
};
fetchImdbId();
}, [tvShowId]);
return ( return (
<Container> <Container>
<ShowInfo> <Content>
<PosterContainer> <PosterContainer>
{show.poster_path && ( {show.poster_path && (
<Image <Image
src={`https://image.tmdb.org/t/p/w500${show.poster_path}`} src={getImageUrl(show.poster_path, 'w500')}
alt={show.name} alt={show.name}
fill width={300}
style={{ objectFit: 'cover' }} height={450}
priority priority
/> />
)} )}
</PosterContainer> </PosterContainer>
<InfoContent> <Info>
<Title>{show.name}</Title> <Title>{show.name}</Title>
<Overview>{show.overview}</Overview> <Overview>{show.overview}</Overview>
<Stats> <Details>
<StatItem> <DetailItem>
<span>Дата выхода: </span> <Label>Дата выхода:</Label>
{formatDate(show.first_air_date)} <Value>
</StatItem> {show.first_air_date ?
<StatItem> (parseRussianDate(show.first_air_date)?.toLocaleDateString('ru-RU') || 'Неизвестно')
<span>Сезонов: </span> : 'Неизвестно'
{show.number_of_seasons} }
</StatItem> </Value>
<StatItem> </DetailItem>
<span>Эпизодов: </span> <DetailItem>
{show.number_of_episodes} <Label>Сезонов:</Label>
</StatItem> <Value>{show.number_of_seasons || 'Неизвестно'}</Value>
</Stats> </DetailItem>
</InfoContent> <DetailItem>
</ShowInfo> <Label>Эпизодов:</Label>
<Value>{show.number_of_episodes || 'Неизвестно'}</Value>
</DetailItem>
<DetailItem>
<Label>Рейтинг:</Label>
<Value>{show.vote_average.toFixed(1)}</Value>
</DetailItem>
</Details>
<PlayerSection> <ButtonContainer>
<SectionTitle>Смотреть онлайн</SectionTitle> <FavoriteButton
mediaId={tvShowId}
mediaType="tv"
title={show.name}
posterPath={show.poster_path || ''}
/>
</ButtonContainer>
</Info>
</Content>
{imdbId && (
<PlayerContainer> <PlayerContainer>
<MoviePlayer <MoviePlayer
id={tvShowId} imdbId={imdbId}
title={show.name} backdrop={show.backdrop_path ? getImageUrl(show.backdrop_path, 'original') : undefined}
poster={show.poster_path ? `https://image.tmdb.org/t/p/w500${show.poster_path}` : ''}
imdbId={show.external_ids?.imdb_id}
/> />
</PlayerContainer> </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> </Container>
); );

View File

@@ -3,7 +3,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import TVShowContent from './TVShowContent'; import TVShowContent from './TVShowContent';
import type { TVShowDetails } from '@/lib/api'; import type { TVShow } from '@/types/movie';
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
@@ -13,7 +13,7 @@ const Container = styled.div`
interface TVShowPageProps { interface TVShowPageProps {
tvShowId: string; tvShowId: string;
show: TVShowDetails | null; show: TVShow | null;
} }
export default function TVShowPage({ tvShowId, show }: TVShowPageProps) { export default function TVShowPage({ tvShowId, show }: TVShowPageProps) {
@@ -21,7 +21,10 @@ export default function TVShowPage({ tvShowId, show }: TVShowPageProps) {
return ( return (
<PageLayout> <PageLayout>
<Container> <Container>
<div>Сериал не найден</div> <div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
<h1 className="text-3xl font-bold mb-4">Сериал не найден</h1>
<p className="text-gray-400">К сожалению, запрашиваемый сериал не существует или был удален.</p>
</div>
</Container> </Container>
</PageLayout> </PageLayout>
); );

View File

@@ -1,5 +1,5 @@
import TVShowPage from './TVShowPage'; import TVShowPage from './TVShowPage';
import { tvAPI } from '@/lib/api'; import { tvShowsAPI } from '@/lib/neoApi';
interface PageProps { interface PageProps {
params: { params: {
@@ -10,8 +10,8 @@ interface PageProps {
async function getData(id: string) { async function getData(id: string) {
try { try {
const response = await tvAPI.getShow(id); const response = await tvShowsAPI.getTVShow(id).then(res => res.data);
return { id, show: response.data }; return { id, show: response };
} catch (error) { } catch (error) {
console.error('Error fetching show:', error); console.error('Error fetching show:', error);
return { id, show: null }; return { id, show: null };

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { favoritesAPI } from '@/lib/favoritesApi';
import { Heart } from 'lucide-react';
import styled from 'styled-components';
import { toast } from 'react-hot-toast';
const Button = styled.button<{ $isFavorite: boolean }>`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'};
border: none;
color: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
}
svg {
width: 1.2rem;
height: 1.2rem;
fill: ${props => props.$isFavorite ? '#ff4444' : 'none'};
stroke: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
transition: all 0.2s;
}
`;
interface FavoriteButtonProps {
mediaId: string | number;
mediaType: 'movie' | 'tv';
title: string;
posterPath: string | null;
className?: string;
}
export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className }: FavoriteButtonProps) {
const { data: session, status } = useSession();
const [isFavorite, setIsFavorite] = useState(false);
// Преобразуем mediaId в строку для сравнения
const mediaIdString = mediaId.toString();
useEffect(() => {
const checkFavorite = async () => {
// Проверяем только если пользователь авторизован
if (status !== 'authenticated' || !session?.user?.email) return;
try {
const response = await favoritesAPI.getFavorites();
const favorites = response.data;
const isFav = favorites.some(
fav => fav.mediaId === mediaIdString && fav.mediaType === mediaType
);
setIsFavorite(isFav);
} catch (error) {
console.error('Error checking favorite status:', error);
}
};
checkFavorite();
}, [session?.user?.email, mediaIdString, mediaType, status]);
const toggleFavorite = async () => {
if (!session?.user?.email) {
toast.error('Для добавления в избранное необходимо авторизоваться');
return;
}
try {
if (isFavorite) {
await favoritesAPI.removeFavorite(mediaIdString, mediaType);
toast.success('Удалено из избранного');
setIsFavorite(false);
} else {
await favoritesAPI.addFavorite({
mediaId: mediaIdString,
mediaType,
title,
posterPath: posterPath || undefined,
});
toast.success('Добавлено в избранное');
setIsFavorite(true);
}
} catch (error) {
console.error('Error toggling favorite:', error);
toast.error('Произошла ошибка');
}
};
return (
<Button type="button" onClick={toggleFavorite} $isFavorite={isFavorite} className={className}>
<Heart />
{isFavorite ? 'В избранном' : 'В избранное'}
</Button>
);
}

View File

@@ -1,47 +1,61 @@
'use client'; 'use client';
import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import styled from 'styled-components'; import styled from 'styled-components';
import { Movie } from '@/types/movie'; import { Movie } from '@/types/movie';
import { formatDate } from '@/lib/utils';
import { useImageLoader } from '@/hooks/useImageLoader';
interface MovieCardProps { interface MovieCardProps {
movie: Movie; movie: Movie;
priority?: boolean;
} }
export default function MovieCard({ movie }: MovieCardProps) { export default function MovieCard({ movie, priority = false }: MovieCardProps) {
const getRatingColor = (rating: number) => { const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342');
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 ( return (
<Card href={`/movie/${movie.id}`}> <Card href={`/movie/${movie.id}`}>
<PosterWrapper> <PosterWrapper>
<Poster {isLoading ? (
src={posterUrl} <div className="flex h-full w-full items-center justify-center bg-gray-700">
alt={movie.title} <div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-white" />
width={200} </div>
height={300} ) : imageUrl ? (
style={{ objectFit: 'cover' }} <Poster
/> src={imageUrl}
alt={movie.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={priority}
className="object-cover transition-opacity duration-300 group-hover:opacity-75"
unoptimized // Отключаем оптимизацию Next.js, так как используем CDN
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-700 text-gray-400">
No Image
</div>
)}
<Rating style={{ backgroundColor: getRatingColor(movie.vote_average) }}> <Rating style={{ backgroundColor: getRatingColor(movie.vote_average) }}>
{movie.vote_average.toFixed(1)} {movie.vote_average.toFixed(1)}
</Rating> </Rating>
</PosterWrapper> </PosterWrapper>
<Content> <Content>
<Title>{movie.title}</Title> <Title>{movie.title}</Title>
<Year>{new Date(movie.release_date).getFullYear()}</Year> <Year>{formatDate(movie.release_date)}</Year>
</Content> </Content>
</Card> </Card>
); );
} }
const getRatingColor = (rating: number) => {
if (rating >= 7) return '#4CAF50';
if (rating >= 5) return '#FFC107';
return '#F44336';
};
const Card = styled(Link)` const Card = styled(Link)`
position: relative; position: relative;
border-radius: 16px; border-radius: 16px;

View File

@@ -4,6 +4,7 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { getImageUrl } from '@/lib/neoApi';
import { Movie, TVShow } from '@/lib/api'; import { Movie, TVShow } from '@/lib/api';
const ResultsContainer = styled.div` const ResultsContainer = styled.div`
@@ -12,7 +13,7 @@ const ResultsContainer = styled.div`
padding: 1rem; padding: 1rem;
`; `;
const ResultItem = styled(Link)` const ResultItem = styled.div`
display: flex; display: flex;
padding: 0.75rem; padding: 0.75rem;
gap: 1rem; gap: 1rem;
@@ -53,56 +54,43 @@ const Year = styled.span`
color: rgba(255, 255, 255, 0.6); 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 { interface SearchResultsProps {
results: (Movie | TVShow)[]; results: (Movie | TVShow)[];
onItemClick: () => void; onItemClick: () => void;
} }
const getYear = (date: string | undefined | null): string => {
if (!date) return '';
const year = date.split(' ')[2]; // Получаем год из формата "DD месяц YYYY г."
return year ? year : '';
};
export default function SearchResults({ results, onItemClick }: SearchResultsProps) { 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 ( return (
<ResultsContainer> <ResultsContainer>
{results.map((item) => ( {results.map((item) => (
<ResultItem <Link
key={item.id} key={`${item.id}-${item.media_type}`}
href={isMovie(item) ? `/movie/${item.id}` : `/tv/${item.id}`} href={`/${item.media_type}/${item.id}`}
onClick={onItemClick} onClick={onItemClick}
> >
<PosterContainer> <ResultItem>
<Image <PosterContainer>
src={item.poster_path <Image
? `https://image.tmdb.org/t/p/w92${item.poster_path}` src={item.poster_path ? getImageUrl(item.poster_path, 'w92') : '/images/placeholder.jpg'}
: '/placeholder.png'} alt={item.title || item.name}
alt={isMovie(item) ? item.title : item.name} width={46}
fill height={69}
style={{ objectFit: 'cover' }} />
/> </PosterContainer>
</PosterContainer> <ItemInfo>
<ItemInfo> <Title>{item.title || item.name}</Title>
<Title> <Year>
{isMovie(item) ? item.title : item.name} {getYear(item.release_date || item.first_air_date)}
<Type>{isMovie(item) ? 'Фильм' : 'Сериал'}</Type> </Year>
</Title> </ItemInfo>
<Year> </ResultItem>
{getYear(isMovie(item) ? item.release_date : item.first_air_date)} </Link>
</Year>
</ItemInfo>
</ResultItem>
))} ))}
</ResultsContainer> </ResultsContainer>
); );

View File

@@ -2,6 +2,7 @@
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import styled from 'styled-components'; import styled from 'styled-components';
import { useRouter } from 'next/navigation';
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
@@ -66,6 +67,7 @@ const SaveButton = styled.button`
export default function SettingsContent() { export default function SettingsContent() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter();
const players = [ const players = [
{ {
@@ -87,6 +89,8 @@ export default function SettingsContent() {
const handlePlayerSelect = (playerId: string) => { const handlePlayerSelect = (playerId: string) => {
updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' }); updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' });
// Возвращаемся на предыдущую страницу
window.history.back();
}; };
return ( return (

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { getImageUrl } from '@/lib/neoApi';
interface Movie { interface Movie {
id: number; id: number;
@@ -13,6 +14,82 @@ interface Movie {
genre_ids: number[]; genre_ids: number[];
} }
interface MovieCardProps {
children: React.ReactNode;
}
const MovieCard: React.FC<MovieCardProps> = ({ children }) => {
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{children}
</div>
);
};
interface PosterContainerProps {
children: React.ReactNode;
}
const PosterContainer: React.FC<PosterContainerProps> = ({ children }) => {
return (
<div className="aspect-w-2 aspect-h-3">
{children}
</div>
);
};
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
alt: string;
width: number;
height: number;
}
const Image: React.FC<ImageProps> = ({ src, alt, width, height, ...props }) => {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
className="object-cover w-full h-full"
{...props}
/>
);
};
interface MovieInfoProps {
children: React.ReactNode;
}
const MovieInfo: React.FC<MovieInfoProps> = ({ children }) => {
return (
<div className="p-4">
{children}
</div>
);
};
interface TitleProps {
children: React.ReactNode;
}
const Title: React.FC<TitleProps> = ({ children }) => {
return (
<h3 className="font-semibold text-lg mb-2">{children}</h3>
);
};
interface YearProps {
children: React.ReactNode;
}
const Year: React.FC<YearProps> = ({ children }) => {
return (
<p className="text-sm text-gray-400 mb-4">{children}</p>
);
};
export default function MovieSearch() { export default function MovieSearch() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Movie[]>([]); const [searchResults, setSearchResults] = useState<Movie[]>([]);
@@ -64,31 +141,26 @@ export default function MovieSearch() {
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{searchResults.map((movie) => ( {searchResults.map((movie) => (
<div <MovieCard key={movie.id}>
key={movie.id} <PosterContainer>
className="bg-gray-800 rounded-lg overflow-hidden" <Image
> src={movie.poster_path ? getImageUrl(movie.poster_path) : '/placeholder.jpg'}
<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} alt={movie.title}
className="object-cover w-full h-full" width={200}
height={300}
/> />
</div> </PosterContainer>
<div className="p-4"> <MovieInfo>
<h3 className="font-semibold text-lg mb-2">{movie.title}</h3> <Title>{movie.title}</Title>
<Year>{new Date(movie.release_date).getFullYear()}</Year>
<p className="text-sm text-gray-400 mb-4"> <p className="text-sm text-gray-400 mb-4">
{new Date(movie.release_date).getFullYear()} {movie.vote_average.toFixed(1)} {movie.vote_average.toFixed(1)}
</p> </p>
<p className="text-sm text-gray-400 line-clamp-3 mb-4"> <p className="text-sm text-gray-400 line-clamp-3 mb-4">
{movie.overview} {movie.overview}
</p> </p>
</div> </MovieInfo>
</div> </MovieCard>
))} ))}
</div> </div>
)} )}

73
src/configs/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import { AuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { compare } from 'bcrypt';
import { connectToDatabase } from '@/lib/mongodb';
export const authOptions: AuthOptions = {
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 isValid = await compare(credentials.password, user.password);
if (!isValid) {
throw new Error('Неверный пароль');
}
if (credentials.isAdminLogin === 'true' && !user.isAdmin) {
throw new Error('У вас нет прав администратора');
}
return {
id: user._id.toString(),
name: user.name,
email: user.email,
verified: user.verified,
isAdmin: user.isAdmin,
adminVerified: user.adminVerified
};
}
})
],
pages: {
signIn: '/login',
error: '/login'
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.verified = user.verified;
token.isAdmin = user.isAdmin;
token.adminVerified = user.adminVerified;
}
return token;
},
async 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
};

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getImageUrl } from '@/lib/neoApi';
export type ImageSize = 'w500' | 'original' | 'w780' | 'w342' | 'w185' | 'w92';
export function useImageLoader(path: string | null, size: ImageSize = 'w500') {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [imageUrl, setImageUrl] = useState<string>('/placeholder.jpg');
useEffect(() => {
if (!path) {
setImageUrl('/placeholder.jpg');
setIsLoading(false);
return;
}
const url = getImageUrl(path, size);
setImageUrl(url);
const img = new Image();
img.src = url;
img.onload = () => {
setIsLoading(false);
setError(null);
};
img.onerror = (e) => {
setIsLoading(false);
setError(e as Error);
setImageUrl('/placeholder.jpg');
};
return () => {
img.onload = null;
img.onerror = null;
};
}, [path, size]);
return { isLoading, error, imageUrl };
}

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { moviesAPI } from '@/lib/api'; import { moviesAPI } from '@/lib/neoApi';
import type { Movie } from '@/lib/api'; import type { Movie } from '@/lib/neoApi';
export function useMovies(initialPage = 1) { export function useMovies(initialPage = 1) {
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
@@ -12,61 +12,66 @@ export function useMovies(initialPage = 1) {
const [page, setPage] = useState(initialPage); const [page, setPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const filterMovies = useCallback((movies: Movie[]) => { // Получаем featured фильм всегда с первой страницы
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 () => { const fetchFeaturedMovie = useCallback(async () => {
try { try {
const response = await moviesAPI.getPopular(1); const response = await moviesAPI.getPopular(1);
const filteredMovies = filterMovies(response.data.results); if (response.data.results.length > 0) {
if (filteredMovies.length > 0) { const firstMovie = response.data.results[0];
const featuredMovieData = await moviesAPI.getMovie(filteredMovies[0].id); if (firstMovie.id) {
setFeaturedMovie(featuredMovieData.data); const movieDetails = await moviesAPI.getMovie(firstMovie.id);
setFeaturedMovie(movieDetails.data);
}
} }
} catch (err) { } catch (err) {
console.error('Ошибка при загрузке featured фильма:', err); console.error('Ошибка при загрузке featured фильма:', err);
} }
}, [filterMovies]); }, []);
// Загружаем фильмы для текущей страницы
const fetchMovies = useCallback(async (pageNum: number) => { const fetchMovies = useCallback(async (pageNum: number) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
setMovies([]); // Очищаем текущие фильмы перед загрузкой новых
console.log('Загрузка страницы:', pageNum);
const response = await moviesAPI.getPopular(pageNum); const response = await moviesAPI.getPopular(pageNum);
const filteredMovies = filterMovies(response.data.results); console.log('Получены данные:', {
setMovies(filteredMovies); page: response.data.page,
results: response.data.results.length,
totalPages: response.data.total_pages
});
setMovies(response.data.results);
setTotalPages(response.data.total_pages); setTotalPages(response.data.total_pages);
setPage(pageNum);
} catch (err) { } catch (err) {
console.error('Ошибка при загрузке фильмов:', err); console.error('Ошибка при загрузке фильмов:', err);
setError('Произошла ошибка при загрузке фильмов'); setError('Произошла ошибка при загрузке фильмов');
setMovies([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterMovies]); }, []);
// Загружаем featured фильм при монтировании
useEffect(() => { useEffect(() => {
fetchFeaturedMovie(); fetchFeaturedMovie();
}, [fetchFeaturedMovie]); }, [fetchFeaturedMovie]);
// Загружаем фильмы при изменении страницы
useEffect(() => { useEffect(() => {
console.log('Изменение страницы на:', page);
fetchMovies(page); fetchMovies(page);
}, [page, fetchMovies]); }, [page, fetchMovies]);
const handlePageChange = useCallback((newPage: number) => { // Обработчик изменения страницы
window.scrollTo({ top: 0, behavior: 'smooth' }); const handlePageChange = useCallback(async (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
console.log('Смена страницы на:', newPage);
setPage(newPage); setPage(newPage);
}, []); window.scrollTo({ top: 0, behavior: 'smooth' });
}, [totalPages]);
return { return {
movies, movies,

181
src/hooks/useTMDBMovies.ts Normal file
View File

@@ -0,0 +1,181 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import type { Movie } from '@/lib/api';
const api = axios.create({
baseURL: '/api/bridge/tmdb',
headers: {
'Content-Type': 'application/json'
}
});
export function useTMDBMovies(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 api.get('/movie/popular', {
params: {
page: 1,
language: 'ru-RU'
}
});
const filteredMovies = filterMovies(response.data.results);
if (filteredMovies.length > 0) {
const featuredMovieData = await api.get(`/movie/${filteredMovies[0].id}`, {
params: {
language: 'ru-RU',
append_to_response: 'credits,videos'
}
});
setFeaturedMovie(featuredMovieData.data);
}
} catch (err) {
console.error('Ошибка при загрузке featured фильма:', err);
}
}, [filterMovies]);
const fetchMovies = useCallback(async (pageNum: number) => {
try {
setLoading(true);
setError(null);
const response = await api.get('/discover/movie', {
params: {
page: pageNum,
language: 'ru-RU',
'vote_count.gte': 100,
'vote_average.gte': 1,
sort_by: 'popularity.desc',
include_adult: false
}
});
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);
}, []);
const searchMovies = useCallback(async (query: string, pageNum: number = 1) => {
try {
setLoading(true);
setError(null);
const response = await api.get('/search/movie', {
params: {
query,
page: pageNum,
language: 'ru-RU',
include_adult: false
}
});
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]);
const getUpcomingMovies = useCallback(async (pageNum: number = 1) => {
try {
setLoading(true);
setError(null);
const response = await api.get('/movie/upcoming', {
params: {
page: pageNum,
language: 'ru-RU',
'vote_count.gte': 100,
'vote_average.gte': 1
}
});
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]);
const getTopRatedMovies = useCallback(async (pageNum: number = 1) => {
try {
setLoading(true);
setError(null);
const response = await api.get('/movie/top_rated', {
params: {
page: pageNum,
language: 'ru-RU',
'vote_count.gte': 100,
'vote_average.gte': 1
}
});
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]);
return {
movies,
featuredMovie,
loading,
error,
totalPages,
currentPage: page,
setPage: handlePageChange,
searchMovies,
getUpcomingMovies,
getTopRatedMovies
};
}

30
src/lib/favoritesApi.ts Normal file
View File

@@ -0,0 +1,30 @@
import axios from 'axios';
// Создаем экземпляр axios
const api = axios.create({
headers: {
'Content-Type': 'application/json'
}
});
export const favoritesAPI = {
// Получить все избранные
getFavorites() {
return api.get('/api/favorites');
},
// Добавить в избранное
addFavorite(data: { mediaId: string; mediaType: 'movie' | 'tv'; title: string; posterPath?: string }) {
return api.post('/api/favorites', data);
},
// Удалить из избранного
removeFavorite(mediaId: string, mediaType: 'movie' | 'tv') {
return api.delete(`/api/favorites/${mediaId}?mediaType=${mediaType}`);
},
// Проверить есть ли в избранном
checkFavorite(mediaId: string, mediaType: 'movie' | 'tv') {
return api.get(`/api/favorites/check/${mediaId}?mediaType=${mediaType}`);
}
};

View File

@@ -0,0 +1,31 @@
import mongoose from 'mongoose';
const favoriteSchema = new mongoose.Schema({
userId: {
type: String,
required: true
},
mediaId: {
type: String,
required: true
},
mediaType: {
type: String,
required: true,
enum: ['movie', 'tv']
},
title: {
type: String,
required: true
},
posterPath: String,
createdAt: {
type: Date,
default: Date.now
}
});
// Составной индекс для уникальности комбинации userId, mediaId и mediaType
favoriteSchema.index({ userId: 1, mediaId: 1, mediaType: 1 }, { unique: true });
export default mongoose.models.Favorite || mongoose.model('Favorite', favoriteSchema);

View File

@@ -28,3 +28,35 @@ export async function connectToDatabase() {
const db = client.db(); const db = client.db();
return { db, client }; return { db, client };
} }
// Инициализация MongoDB
export async function initMongoDB() {
try {
const { db } = await connectToDatabase();
// Создаем уникальный индекс для избранного
await db.collection('favorites').createIndex(
{ userId: 1, mediaId: 1, mediaType: 1 },
{ unique: true }
);
console.log('MongoDB initialized successfully');
} catch (error) {
console.error('Error initializing MongoDB:', error);
throw error;
}
}
// Функция для сброса и создания индексов
export async function resetIndexes() {
const { db } = await connectToDatabase();
// Удаляем все индексы из коллекции favorites
await db.collection('favorites').dropIndexes();
// Создаем новый правильный индекс
await db.collection('favorites').createIndex(
{ userId: 1, mediaId: 1, mediaType: 1 },
{ unique: true }
);
}

154
src/lib/neoApi.ts Normal file
View File

@@ -0,0 +1,154 @@
import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export const neoApi = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
},
timeout: 30000 // Увеличиваем таймаут до 30 секунд
});
// Добавляем перехватчики запросов
neoApi.interceptors.request.use(
(config) => {
if (config.params?.page) {
const page = parseInt(config.params.page);
if (isNaN(page) || page < 1) {
config.params.page = 1;
}
}
return config;
},
(error) => {
console.error('❌ Request Error:', error);
return Promise.reject(error);
}
);
// Добавляем перехватчики ответов
neoApi.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.error('❌ Response Error:', {
status: error.response?.status,
url: error.config?.url,
method: error.config?.method,
message: error.message
});
return Promise.reject(error);
}
);
// Функция для получения URL изображения
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
if (!path) return '/images/placeholder.jpg';
// Извлекаем только ID изображения из полного пути
const imageId = path.split('/').pop();
if (!imageId) return '/images/placeholder.jpg';
return `${API_URL}/images/${size}/${imageId}`;
};
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?: Genre[];
}
export interface MovieResponse {
page: number;
results: Movie[];
total_pages: number;
total_results: number;
}
export const searchAPI = {
// Мультипоиск (фильмы и сериалы)
multiSearch(query: string, page = 1) {
return neoApi.get<MovieResponse>('/search/multi', {
params: {
query,
page
},
timeout: 30000 // Увеличиваем таймаут до 30 секунд
});
}
};
export const moviesAPI = {
// Получение популярных фильмов
getPopular(page = 1) {
return neoApi.get<MovieResponse>('/movies/popular', {
params: { page },
timeout: 30000
});
},
// Получение данных о фильме по его ID
getMovie(id: string | number) {
return neoApi.get(`/movies/${id}`, { timeout: 30000 });
},
// Поиск фильмов
searchMovies(query: string, page = 1) {
return neoApi.get<MovieResponse>('/movies/search', {
params: {
query,
page
},
timeout: 30000
});
},
// Получение IMDB ID
getImdbId(id: string | number) {
return neoApi.get(`/movies/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
}
};
export const tvShowsAPI = {
// Получение популярных сериалов
getPopular(page = 1) {
return neoApi.get('/tv/popular', {
params: { page },
timeout: 30000
});
},
// Получение данных о сериале по его ID
getTVShow(id: string | number) {
return neoApi.get(`/tv/${id}`, { timeout: 30000 });
},
// Поиск сериалов
searchTVShows(query: string, page = 1) {
return neoApi.get('/tv/search', {
params: {
query,
page
},
timeout: 30000
});
},
// Получение IMDB ID
getImdbId(id: string | number) {
return neoApi.get(`/tv/${id}/external-ids`, { timeout: 30000 }).then(res => res.data.imdb_id);
}
};

View File

@@ -7,10 +7,28 @@ export const validateEmail = (email: string) => {
return re.test(email); return re.test(email);
}; };
export const formatDate = (date: Date) => { export const formatDate = (dateString: string | Date | undefined | null) => {
return new Intl.DateTimeFormat('ru-RU', { if (!dateString) return 'Нет даты';
year: 'numeric',
month: 'long', // Если это строка и она уже содержит "г." (формат с API), возвращаем как есть
day: 'numeric', if (typeof dateString === 'string' && dateString.includes(' г.')) {
}).format(date); return dateString;
}
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) {
return 'Нет даты';
}
return new Intl.DateTimeFormat('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date) + ' г.';
} catch (error) {
console.error('Error formatting date:', error);
return 'Нет даты';
}
}; };

View File

@@ -1,15 +1,47 @@
export interface Movie { export interface Genre {
_id: string; id: number;
title: string; name: string;
description: string; }
year: number;
rating: number; export interface Movie {
posterUrl: string; id: number;
genres: string[]; title: string;
director: string; overview: string;
cast: string[]; poster_path: string | null;
duration: number; backdrop_path: string | null;
trailerUrl?: string; release_date: string;
createdAt: string; vote_average: number;
updatedAt: string; vote_count: number;
genre_ids: number[];
runtime?: number;
genres?: Genre[];
}
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[];
number_of_seasons?: number;
number_of_episodes?: number;
genres?: Genre[];
}
export interface MovieResponse {
page: number;
results: Movie[];
total_pages: number;
total_results: number;
}
export interface TVShowResponse {
page: number;
results: TVShow[];
total_pages: number;
total_results: number;
} }