mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 09:58:49 +05:00
Release 2.5
This commit is contained in:
@@ -3,38 +3,60 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { categoriesAPI, Movie } from '@/lib/neoApi';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
type MediaType = 'movies' | 'tv';
|
||||
// Маппинг ID категорий к их названиям (так как нет эндпоинта getCategory)
|
||||
const categoryNames: Record<number, string> = {
|
||||
12: 'приключения',
|
||||
10751: 'семейный',
|
||||
10752: 'военный',
|
||||
10762: 'Детский',
|
||||
10764: 'Реалити-шоу',
|
||||
10749: 'мелодрама',
|
||||
28: 'боевик',
|
||||
80: 'криминал',
|
||||
18: 'драма',
|
||||
14: 'фэнтези',
|
||||
27: 'ужасы',
|
||||
10402: 'музыка',
|
||||
10770: 'телевизионный фильм',
|
||||
16: 'мультфильм',
|
||||
99: 'документальный',
|
||||
878: 'фантастика',
|
||||
37: 'вестерн',
|
||||
10765: 'НФ и Фэнтези',
|
||||
10767: 'Ток-шоу',
|
||||
10768: 'Война и Политика',
|
||||
9648: 'детектив',
|
||||
35: 'комедия',
|
||||
36: 'история',
|
||||
53: 'триллер',
|
||||
10759: 'Боевик и Приключения',
|
||||
10763: 'Новости',
|
||||
10766: 'Мыльная опера'
|
||||
};
|
||||
|
||||
function CategoryPage() {
|
||||
const { t, locale } = useTranslation();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const categoryId = parseInt(params.id as string);
|
||||
|
||||
const [categoryName, setCategoryName] = useState<string>('');
|
||||
const [mediaType, setMediaType] = useState<MediaType>('movies');
|
||||
const [items, setItems] = useState<Movie[]>([]);
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [moviesAvailable, setMoviesAvailable] = useState(true);
|
||||
const [tvShowsAvailable, setTvShowsAvailable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCategoryName() {
|
||||
try {
|
||||
const response = await categoriesAPI.getCategory(categoryId);
|
||||
setCategoryName(response.data.name);
|
||||
} catch (error) {
|
||||
console.error('Error fetching category:', error);
|
||||
setError('Не удалось загрузить информацию о категории');
|
||||
}
|
||||
}
|
||||
if (categoryId) {
|
||||
fetchCategoryName();
|
||||
// Устанавливаем название категории из переводов
|
||||
if (categoryId && t.categories.names[categoryId as keyof typeof t.categories.names]) {
|
||||
setCategoryName(t.categories.names[categoryId as keyof typeof t.categories.names]);
|
||||
} else {
|
||||
setCategoryName(t.categories.unknownCategory);
|
||||
}
|
||||
}, [categoryId]);
|
||||
|
||||
@@ -46,47 +68,27 @@ function CategoryPage() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (mediaType === 'movies') {
|
||||
response = await categoriesAPI.getMoviesByCategory(categoryId, page);
|
||||
const hasMovies = response.data.results.length > 0;
|
||||
if (page === 1) setMoviesAvailable(hasMovies);
|
||||
setItems(response.data.results);
|
||||
setTotalPages(response.data.total_pages);
|
||||
if (!hasMovies && tvShowsAvailable && page === 1) {
|
||||
setMediaType('tv');
|
||||
}
|
||||
} else {
|
||||
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
||||
const hasTvShows = response.data.results.length > 0;
|
||||
if (page === 1) setTvShowsAvailable(hasTvShows);
|
||||
const transformedShows = response.data.results.map((show: any) => ({
|
||||
...show,
|
||||
title: show.name || show.title,
|
||||
release_date: show.first_air_date || show.release_date,
|
||||
}));
|
||||
setItems(transformedShows);
|
||||
setTotalPages(response.data.total_pages);
|
||||
if (!hasTvShows && moviesAvailable && page === 1) {
|
||||
setMediaType('movies');
|
||||
}
|
||||
}
|
||||
// Выбираем источник по языку: ru -> kp, иначе tmdb
|
||||
const source = (locale || 'ru') === 'ru' ? 'kp' : 'tmdb';
|
||||
// Получаем и фильмы, и сериалы, объединяем, чтобы не было пустых категорий
|
||||
const [tvRes, movieRes]: any = await Promise.all([
|
||||
categoriesAPI.getMediaByCategory(categoryId, 'tv', page, undefined, source, t.categories?.names?.[categoryId as any] ?? ''),
|
||||
categoriesAPI.getMediaByCategory(categoryId, 'movie', page, undefined, source, t.categories?.names?.[categoryId as any] ?? '')
|
||||
]);
|
||||
const tvItems = tvRes?.results || [];
|
||||
const movieItems = movieRes?.results || [];
|
||||
const combined = [...tvItems, ...movieItems];
|
||||
setMovies(combined);
|
||||
setTotalPages(Math.max(tvRes?.total_pages || 1, movieRes?.total_pages || 1));
|
||||
} catch (err) {
|
||||
setError('Ошибка при загрузке данных');
|
||||
setError(t.categories.errorLoadingMovies);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [categoryId, mediaType, page, moviesAvailable, tvShowsAvailable]);
|
||||
|
||||
const handleMediaTypeChange = (type: MediaType) => {
|
||||
if (type === 'movies' && !moviesAvailable) return;
|
||||
if (type === 'tv' && !tvShowsAvailable) return;
|
||||
setMediaType(type);
|
||||
setPage(1);
|
||||
};
|
||||
}, [categoryId, page]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
@@ -166,7 +168,7 @@ function CategoryPage() {
|
||||
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 mb-4 px-4 py-2 rounded-md bg-card hover:bg-card/80 text-foreground">
|
||||
<ArrowLeft size={16} />
|
||||
Назад к категориям
|
||||
{t.common.backToCategories}
|
||||
</button>
|
||||
<div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
|
||||
{error}
|
||||
@@ -182,45 +184,28 @@ function CategoryPage() {
|
||||
Назад к категориям
|
||||
</button>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
|
||||
{categoryName || 'Загрузка...'}
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground capitalize">
|
||||
{categoryName}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => handleMediaTypeChange('movies')}
|
||||
disabled={!moviesAvailable || mediaType === 'movies'}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${mediaType === 'movies' ? 'bg-accent text-white' : 'bg-card hover:bg-card/80 text-foreground'}`}
|
||||
>
|
||||
Фильмы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMediaTypeChange('tv')}
|
||||
disabled={!tvShowsAvailable || mediaType === 'tv'}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${mediaType === 'tv' ? 'bg-accent text-white' : 'bg-card hover:bg-card/80 text-foreground'}`}
|
||||
>
|
||||
Сериалы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center min-h-[40vh]">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.length > 0 ? (
|
||||
{movies.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6">
|
||||
{items.map(item => (
|
||||
{movies.map(movie => (
|
||||
<MovieCard
|
||||
key={`${mediaType}-${item.id}-${page}`}
|
||||
movie={item}
|
||||
key={`movie-${movie.id}-${page}`}
|
||||
movie={movie}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p>Нет {mediaType === 'movies' ? 'фильмов' : 'сериалов'} в этой категории.</p>
|
||||
<p>{t.categories.noMoviesInCategory}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { categoriesAPI, Category } from '@/lib/neoApi';
|
||||
import CategoryCard from '@/components/CategoryCard';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
interface CategoryWithBackground extends Category {
|
||||
backgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
function CategoriesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<CategoryWithBackground[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -21,46 +23,24 @@ function CategoriesPage() {
|
||||
try {
|
||||
const categoriesResponse = await categoriesAPI.getCategories();
|
||||
|
||||
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
|
||||
if (!categoriesResponse.data || categoriesResponse.data.length === 0) {
|
||||
setError('Не удалось загрузить категории');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
||||
categoriesResponse.data.categories.map(async (category: Category) => {
|
||||
categoriesResponse.data.map(async (category: Category) => {
|
||||
try {
|
||||
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
|
||||
|
||||
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
|
||||
const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
|
||||
moviesResponse.data.results[0].poster_path;
|
||||
|
||||
return {
|
||||
...category,
|
||||
backgroundUrl
|
||||
};
|
||||
} else {
|
||||
const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1);
|
||||
|
||||
if (tvResponse.data.results && tvResponse.data.results.length > 0) {
|
||||
const backgroundUrl = tvResponse.data.results[0].backdrop_path ||
|
||||
tvResponse.data.results[0].poster_path;
|
||||
|
||||
return {
|
||||
...category,
|
||||
backgroundUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...category,
|
||||
backgroundUrl: undefined
|
||||
};
|
||||
// Пробуем получить сериал как фон, иначе фильм
|
||||
const tvRes: any = await categoriesAPI.getMediaByCategory(category.id, 'tv', 1);
|
||||
const movieRes: any = await categoriesAPI.getMediaByCategory(category.id, 'movie', 1);
|
||||
const pick = tvRes?.results?.[0] || movieRes?.results?.[0];
|
||||
const backgroundUrl = pick?.backdrop_path || pick?.poster_path || null;
|
||||
return { ...category, backgroundUrl };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching background for category ${category.id}:`, error);
|
||||
return category;
|
||||
return { ...category, backgroundUrl: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -68,7 +48,7 @@ function CategoriesPage() {
|
||||
setCategories(categoriesWithBackgrounds);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setError('Ошибка при загрузке категорий');
|
||||
setError(t.categories.errorLoadingCategories);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { neoApi, getImageUrl } from '@/lib/neoApi';
|
||||
import { Loader2, HeartCrack } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
interface Favorite {
|
||||
id: number;
|
||||
@@ -16,6 +17,7 @@ interface Favorite {
|
||||
}
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
@@ -30,16 +32,14 @@ export default function FavoritesPage() {
|
||||
|
||||
try {
|
||||
const response = await neoApi.get('/api/v1/favorites');
|
||||
setFavorites(response.data);
|
||||
// Обрабатываем как обёрнутый, так и прямой ответ
|
||||
const data = response.data?.data || response.data;
|
||||
setFavorites(Array.isArray(data) ? data : []);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch favorites:', error);
|
||||
// Редиректим только при явном 401
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userName');
|
||||
localStorage.removeItem('userEmail');
|
||||
router.replace('/login');
|
||||
}
|
||||
// 401 ошибки теперь обрабатываются автоматически через interceptor
|
||||
// Здесь просто устанавливаем пустой массив при ошибке
|
||||
setFavorites([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,15 +61,15 @@ export default function FavoritesPage() {
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<HeartCrack size={80} className="mx-auto mb-6 text-gray-400" />
|
||||
<h1 className="text-3xl font-bold text-foreground mb-4">Избранное пусто</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-4">{t.favorites.empty}</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
У вас пока нет избранных фильмов и сериалов
|
||||
{t.favorites.emptyDescription}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-accent rounded-lg hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Найти фильмы
|
||||
{t.favorites.goToMovies}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ export default function FavoritesPage() {
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">Избранное</h1>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">{t.favorites.title}</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Ваша коллекция любимых фильмов и сериалов
|
||||
</p>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
export default function LoginClient() {
|
||||
const { t } = useTranslation();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -34,7 +36,7 @@ export default function LoginClient() {
|
||||
router.push(`/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
||||
setError(err instanceof Error ? err.message : t.common.error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -53,7 +55,7 @@ export default function LoginClient() {
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя"
|
||||
placeholder={t.auth.name}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required={!isLogin}
|
||||
@@ -76,7 +78,7 @@ export default function LoginClient() {
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
placeholder={t.auth.password}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -89,7 +91,7 @@ export default function LoginClient() {
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 px-4 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Загрузка...' : isLogin ? 'Войти' : 'Зарегистрироваться'}
|
||||
{isLoading ? t.common.loading : isLogin ? t.auth.loginButton : t.auth.registerButton}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -98,7 +100,7 @@ export default function LoginClient() {
|
||||
<div className="w-full border-t border-warm-200 dark:border-warm-700"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-warm-50 dark:bg-warm-900 text-warm-500">или</span>
|
||||
<span className="px-2 bg-warm-50 dark:bg-warm-900 text-warm-500">{t.common.or}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +110,7 @@ export default function LoginClient() {
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-warm-200 dark:border-warm-700 rounded-lg bg-white dark:bg-warm-800 hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-900 dark:text-warm-50 transition-colors"
|
||||
>
|
||||
<Image src="/google.svg" alt="Google" width={20} height={20} />
|
||||
Продолжить с Google
|
||||
{t.auth.continueWithGoogle}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
@@ -118,13 +120,13 @@ export default function LoginClient() {
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
|
||||
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}{' '}
|
||||
{isLogin ? t.auth.noAccount : t.auth.haveAccount}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-accent hover:underline focus:outline-none"
|
||||
>
|
||||
{isLogin ? 'Зарегистрироваться' : 'Войти'}
|
||||
{isLogin ? t.auth.registerButton : t.auth.loginButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
44
src/app/movie/[...slug]/page.tsx
Normal file
44
src/app/movie/[...slug]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
import MoviePage from '../_components/MoviePage';
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const slugParam = params.slug as string[] | undefined;
|
||||
const [movie, setMovie] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sourceId = (() => {
|
||||
if (!slugParam || slugParam.length === 0) return '';
|
||||
if (slugParam.length === 1) return slugParam[0]; // new: kp_123
|
||||
if (slugParam.length >= 2) return `${slugParam[0]}_${slugParam[1]}`; // old: kp/123
|
||||
return '';
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMovie() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await moviesAPI.getMovieBySourceId(sourceId);
|
||||
setMovie(response.data);
|
||||
} catch (err: any) {
|
||||
setError(`${t.common.failedToLoad}: ${err?.message || t.common.unknownError}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (sourceId) fetchMovie();
|
||||
}, [sourceId]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><Loader2 className="w-12 h-12 animate-spin text-accent" /></div>;
|
||||
if (error || !movie) return <div className="flex items-center justify-center min-h-screen"><p>{error || t.common.movieNotFound}</p></div>;
|
||||
|
||||
return <MoviePage movieId={movie.id} movie={movie} />;
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
import type { MovieDetails } from '@/lib/neoApi';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import Reactions from '@/components/Reactions';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
import { NextSeo } from 'next-seo';
|
||||
|
||||
interface MovieContentProps {
|
||||
movieId: string;
|
||||
initialMovie: MovieDetails;
|
||||
}
|
||||
|
||||
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
||||
const [movie] = useState<MovieDetails>(initialMovie);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
const data = await moviesAPI.getExternalIds(movieId);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
fetchExternalIds();
|
||||
}, [movieId]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`${movie.title} смотреть онлайн`}
|
||||
description={movie.overview?.slice(0, 150)}
|
||||
canonical={`https://neomovies.ru/movie/${movie.id}`}
|
||||
openGraph={{
|
||||
url: `https://neomovies.ru/movie/${movie.id}`,
|
||||
images: [
|
||||
{
|
||||
url: getImageUrl(movie.poster_path, 'w780'),
|
||||
alt: movie.title,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
{/* schema.org Movie */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Movie',
|
||||
name: movie.title,
|
||||
image: getImageUrl(movie.poster_path, 'w780'),
|
||||
description: movie.overview,
|
||||
datePublished: movie.release_date,
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: movie.vote_average,
|
||||
ratingCount: movie.vote_count,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{/* Left Column: Poster */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(movie.poster_path, 'w500')}
|
||||
alt={`Постер фильма ${movie.title}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Details */}
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{movie.title}
|
||||
</h1>
|
||||
{movie.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{movie.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">Рейтинг: {movie.vote_average.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{movie.runtime} мин.</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{formatDate(movie.release_date)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{movie.genres.map((genre) => (
|
||||
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||
<p>{movie.overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{/* Mobile-only Watch Button */}
|
||||
{imdbId && (
|
||||
<button
|
||||
onClick={handleOpenPlayer}
|
||||
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||
>
|
||||
<PlayCircle size={20} />
|
||||
<span>Смотреть</span>
|
||||
</button>
|
||||
)}
|
||||
<FavoriteButton
|
||||
mediaId={movie.id.toString()}
|
||||
mediaType="movie"
|
||||
title={movie.title}
|
||||
posterPath={movie.poster_path}
|
||||
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Reactions mediaId={movie.id.toString()} mediaType="movie" />
|
||||
</div>
|
||||
|
||||
{/* Desktop-only Embedded Player */}
|
||||
{imdbId && (
|
||||
<div className="mt-10 space-y-4">
|
||||
<div id="movie-player" className="hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||
<MoviePlayer
|
||||
id={movie.id.toString()}
|
||||
title={movie.title}
|
||||
poster={movie.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
</div>
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="movie"
|
||||
title={movie.title}
|
||||
originalTitle={movie.original_title}
|
||||
year={movie.release_date?.split('-')[0]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Player for Mobile */}
|
||||
{isPlayerFullscreen && imdbId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<MoviePlayer
|
||||
id={movie.id.toString()}
|
||||
title={movie.title}
|
||||
poster={movie.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Metadata } from 'next';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import MoviePage from '@/app/movie/[id]/MoviePage';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||
const { params } = await props;
|
||||
try {
|
||||
const movieId = params.id;
|
||||
|
||||
const { data: movie } = await moviesAPI.getMovie(movieId);
|
||||
|
||||
return {
|
||||
title: `${movie.title} - NeoMovies`,
|
||||
description: movie.overview,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating metadata:', error);
|
||||
return {
|
||||
title: 'Фильм - NeoMovies',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getData(id: string) {
|
||||
try {
|
||||
const { data: movie } = await moviesAPI.getMovie(id);
|
||||
return { id, movie };
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch movie');
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = params;
|
||||
const data = await getData(id);
|
||||
return <MoviePage movieId={data.id} movie={data.movie} />;
|
||||
}
|
||||
291
src/app/movie/_components/MovieContent.tsx
Normal file
291
src/app/movie/_components/MovieContent.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import { getImageUrl as getImageUrlOriginal } from '@/lib/neoApi';
|
||||
import type { MovieDetails } from '@/lib/neoApi';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import Reactions from '@/components/Reactions';
|
||||
import PlayerSelector from '@/components/PlayerSelector';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
import { unifyMovieData, formatRating, formatRuntime, getImageUrl } from '@/lib/dataUtils';
|
||||
|
||||
interface MovieContentProps {
|
||||
movieId: string;
|
||||
initialMovie: MovieDetails;
|
||||
}
|
||||
|
||||
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const [movie] = useState<MovieDetails>(initialMovie);
|
||||
const unified = unifyMovieData(movie);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(unified.imdbId || null);
|
||||
const [kinopoiskId, setKinopoiskId] = useState<number | null>(unified.kinopoiskId || null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string>(settings.defaultPlayer);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sync selectedPlayer with settings
|
||||
useEffect(() => {
|
||||
setSelectedPlayer(settings.defaultPlayer);
|
||||
}, [settings.defaultPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Movie data:', movie);
|
||||
|
||||
// Check if we have unified data with externalIds
|
||||
if (movie.externalIds) {
|
||||
console.log('Found externalIds in unified data:', movie.externalIds);
|
||||
setExternalIds(movie.externalIds);
|
||||
if (movie.externalIds.imdb) {
|
||||
console.log('Found imdb_id in externalIds:', movie.externalIds.imdb);
|
||||
setImdbId(movie.externalIds.imdb);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if imdb_id is already in movie details
|
||||
if (movie.imdb_id) {
|
||||
console.log('Found imdb_id in movie details:', movie.imdb_id);
|
||||
setImdbId(movie.imdb_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not, fetch from external_ids endpoint
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
console.log('Fetching external IDs for movie:', movieId);
|
||||
const data = await moviesAPI.getExternalIds(movieId);
|
||||
console.log('External IDs response:', data);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id) {
|
||||
console.log('Found imdb_id in external_ids:', data.imdb_id);
|
||||
setImdbId(data.imdb_id);
|
||||
} else {
|
||||
console.warn('No imdb_id found in external_ids response');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
fetchExternalIds();
|
||||
}, [movieId, movie.imdb_id]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`${unified.title} ${t.details.watchOnline}`}
|
||||
description={unified.overview?.slice(0, 150)}
|
||||
canonical={`https://neomovies.ru/movie/${unified.id}`}
|
||||
openGraph={{
|
||||
url: `https://neomovies.ru/movie/${unified.id}`,
|
||||
images: [
|
||||
{
|
||||
url: getImageUrl(unified.posterPath, 'w780'),
|
||||
alt: unified.title,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Movie',
|
||||
name: unified.title,
|
||||
image: getImageUrl(unified.posterPath, 'w780'),
|
||||
description: unified.overview,
|
||||
datePublished: unified.releaseDate,
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: unified.voteAverage,
|
||||
ratingCount: unified.voteCount,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(unified.posterPath, 'w500')}
|
||||
alt={unified.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{unified.title}
|
||||
</h1>
|
||||
{unified.originalTitle && unified.originalTitle !== unified.title && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{unified.originalTitle}</p>
|
||||
)}
|
||||
{movie.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{movie.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">{t.details.rating}: {formatRating(unified.voteAverage)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
{unified.runtime && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{formatRuntime(unified.runtime)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">{unified.year}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{unified.genres.filter(g => g.name).map((genre, idx) => (
|
||||
<span key={idx} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||
<p>{unified.overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{/* Mobile-only Watch Button */}
|
||||
{imdbId && (
|
||||
<button
|
||||
onClick={handleOpenPlayer}
|
||||
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||
>
|
||||
<PlayCircle size={20} />
|
||||
<span>{t.details.watchNow}</span>
|
||||
</button>
|
||||
)}
|
||||
<FavoriteButton
|
||||
mediaId={unified.id.toString()}
|
||||
mediaType="movie"
|
||||
title={unified.title}
|
||||
posterPath={unified.posterPath}
|
||||
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Reactions
|
||||
mediaId={externalIds?.tmdb ? externalIds.tmdb.toString() : unified.id.toString()}
|
||||
mediaType="movie"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(imdbId || kinopoiskId) && (
|
||||
<div className="mt-10 space-y-4">
|
||||
<div id="movie-player" className="rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||
<PlayerSelector
|
||||
language={settings.language}
|
||||
selectedPlayer={selectedPlayer}
|
||||
onPlayerChange={setSelectedPlayer}
|
||||
/>
|
||||
<MoviePlayer
|
||||
id={unified.id.toString()}
|
||||
title={unified.title}
|
||||
poster={unified.posterPath || ''}
|
||||
imdbId={imdbId || undefined}
|
||||
kinopoiskId={kinopoiskId?.toString()}
|
||||
selectedPlayer={selectedPlayer}
|
||||
/>
|
||||
</div>
|
||||
{imdbId && (
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="movie"
|
||||
title={unified.title}
|
||||
originalTitle={unified.originalTitle}
|
||||
year={unified.year}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlayerFullscreen && (imdbId || kinopoiskId) && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<div className={`absolute top-4 left-4 z-50 transition-opacity duration-300 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/75"
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`w-full px-4 pt-16 pb-4 transition-opacity duration-300 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<PlayerSelector
|
||||
language={settings.language}
|
||||
selectedPlayer={selectedPlayer}
|
||||
onPlayerChange={setSelectedPlayer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full flex items-center justify-center">
|
||||
<MoviePlayer
|
||||
id={unified.id.toString()}
|
||||
title={unified.title}
|
||||
poster={unified.posterPath || ''}
|
||||
imdbId={imdbId || undefined}
|
||||
kinopoiskId={kinopoiskId?.toString()}
|
||||
selectedPlayer={selectedPlayer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/app/movie/page.tsx
Normal file
1
src/app/movie/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function Page(){ return null; }
|
||||
@@ -1,18 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/contexts/TranslationContext";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
|
||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8 text-center">
|
||||
<h1 className="text-5xl font-bold mb-4">404</h1>
|
||||
<p className="text-lg text-muted-foreground mb-6">Страница не найдена</p>
|
||||
<p className="text-lg text-muted-foreground mb-6">{t.common.pageNotFound || 'Страница не найдена'}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-accent text-white px-6 py-3 rounded-lg font-medium hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2"
|
||||
>
|
||||
На главную
|
||||
{t.nav.home}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useMovies, MovieCategory } from '@/hooks/useMovies';
|
||||
import MovieTile from '@/components/MovieTile';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import HorizontalSlider from '@/components/HorizontalSlider';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
|
||||
const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
|
||||
|
||||
@@ -18,7 +20,7 @@ export default function HomePage() {
|
||||
if (loading && !movies.length) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
Загрузка...
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +48,7 @@ export default function HomePage() {
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Популярные
|
||||
{t.home.popular}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('now-playing')}
|
||||
@@ -56,7 +58,7 @@ export default function HomePage() {
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Новинки
|
||||
{t.home.nowPlaying}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('top-rated')}
|
||||
@@ -66,18 +68,9 @@ export default function HomePage() {
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Топ рейтинга
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('upcoming')}
|
||||
className={`${
|
||||
activeTab === 'upcoming'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Скоро
|
||||
{t.home.topRated}
|
||||
</button>
|
||||
{/* Удалена вкладка "Скоро" */}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, User, LogOut, Trash2, ArrowLeft } from 'lucide-react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
@@ -30,12 +32,12 @@ export default function ProfilePage() {
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await authAPI.deleteAccount();
|
||||
toast.success('Аккаунт успешно удален.');
|
||||
toast.success(t.profile.accountDeleted);
|
||||
setIsModalOpen(false);
|
||||
logout();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete account:', error);
|
||||
toast.error('Не удалось удалить аккаунт. Попробуйте снова.');
|
||||
toast.error(t.profile.deleteFailed);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -57,7 +59,7 @@ export default function ProfilePage() {
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Назад</span>
|
||||
<span>{t.common.back}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8 text-center mb-6">
|
||||
@@ -69,25 +71,25 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8 mb-6">
|
||||
<h2 className="text-xl font-bold text-foreground mb-4 text-left">Управление аккаунтом</h2>
|
||||
<h2 className="text-xl font-bold text-foreground mb-4 text-left">{t.profile.accountManagement}</h2>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full sm:w-auto px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-accent hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>Выйти из аккаунта</span>
|
||||
<span>{t.profile.logout}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-500/10 border-2 border-dashed border-red-500/50 rounded-lg p-6 sm:p-8 text-center">
|
||||
<h2 className="text-xl font-bold text-red-500 mb-4">Опасная зона</h2>
|
||||
<p className="text-red-400 mb-6">Это действие нельзя будет отменить. Все ваши данные, включая избранное, будут удалены.</p>
|
||||
<h2 className="text-xl font-bold text-red-500 mb-4">{t.profile.dangerZone}</h2>
|
||||
<p className="text-red-400 mb-6">{t.profile.deleteWarning}</p>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="w-full sm:w-auto px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
<span>Удалить аккаунт</span>
|
||||
<span>{t.profile.deleteAccount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,9 +97,9 @@ export default function ProfilePage() {
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Подтвердите удаление аккаунта"
|
||||
title={t.profile.confirmDelete}
|
||||
>
|
||||
<p>Вы уверены, что хотите навсегда удалить свой аккаунт? Все ваши данные, включая избранное и реакции, будут безвозвратно удалены. Это действие нельзя будет отменить.</p>
|
||||
<p>{t.profile.confirmDeleteText}</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,61 +5,193 @@ import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { searchAPI } from '@/lib/neoApi';
|
||||
import type { Movie } from '@/lib/neoApi';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import type { UnifiedSearchItem } from '@/lib/unifiedTypes';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
export default function SearchClient() {
|
||||
const { t, locale } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const [results, setResults] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalResults, setTotalResults] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQuery = searchParams.get('q');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
if (currentQuery) {
|
||||
setLoading(true);
|
||||
searchAPI.multiSearch(currentQuery)
|
||||
.then((response) => {
|
||||
setResults(response.data.results || []);
|
||||
setCurrentPage(page);
|
||||
|
||||
// Выбор источника: ru -> kp, иначе tmdb
|
||||
const source: 'kp' | 'tmdb' = (locale || 'ru') === 'ru' ? 'kp' : 'tmdb';
|
||||
searchAPI.multiSearch(currentQuery, source, page)
|
||||
.then((body) => {
|
||||
const items = (body?.data || []) as UnifiedSearchItem[];
|
||||
// Адаптируем к ожидаемой MovieCard структуре
|
||||
const mapped: any[] = items.map(it => ({
|
||||
id: parseInt(it.id, 10),
|
||||
title: it.title,
|
||||
name: it.title,
|
||||
media_type: it.type === 'tv' ? 'tv' : 'movie',
|
||||
release_date: it.releaseDate,
|
||||
first_air_date: it.releaseDate,
|
||||
poster_path: it.posterUrl?.startsWith('http') ? it.posterUrl : it.posterUrl,
|
||||
vote_average: it.rating || 0,
|
||||
overview: it.description || ''
|
||||
}));
|
||||
setResults(mapped);
|
||||
setTotalResults(body?.pagination?.totalResults || 0);
|
||||
setTotalPages(body?.pagination?.totalPages || 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Search failed:', error);
|
||||
setResults([]);
|
||||
setTotalResults(0);
|
||||
setTotalPages(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setResults([]);
|
||||
setTotalResults(0);
|
||||
setTotalPages(0);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
if (query.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
router.push(`/search?q=${encodeURIComponent(currentQuery)}&page=${newPage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
// Предыдущая страница
|
||||
if (currentPage > 1) {
|
||||
pages.push(
|
||||
<button
|
||||
key="prev"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className="px-3 py-2 mx-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Страницы
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePageChange(i)}
|
||||
className={`px-3 py-2 mx-1 text-sm rounded ${
|
||||
i === currentPage
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Следующая страница
|
||||
if (currentPage < totalPages) {
|
||||
pages.push(
|
||||
<button
|
||||
key="next"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className="px-3 py-2 mx-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center mt-8">
|
||||
<div className="flex items-center">
|
||||
{pages}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{searchParams.get('q') && (
|
||||
<h1 className="text-2xl font-bold mb-8 text-center">
|
||||
Результаты поиска для: <span className="text-primary">"{searchParams.get('q')}"</span>
|
||||
</h1>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
{t.search.resultsFor} <span className="text-primary">"{searchParams.get('q')}"</span>
|
||||
</h1>
|
||||
{totalResults > 0 && (
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t.search.found} {totalResults} {
|
||||
totalResults === 1
|
||||
? t.search.result_one
|
||||
: totalResults < 5
|
||||
? t.search.result_few
|
||||
: t.search.result_many
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center">Загрузка...</div>
|
||||
) : results.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{results.map((item) => (
|
||||
<MovieCard
|
||||
key={`${item.id}-${item.media_type || 'movie'}`}
|
||||
movie={item}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||
<p>{t.search.loadingResults}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{results.map((item) => (
|
||||
<MovieCard
|
||||
key={`${item.id}-${item.media_type || 'movie'}`}
|
||||
movie={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</>
|
||||
) : (
|
||||
searchParams.get('q') && (
|
||||
<div className="text-center">Ничего не найдено.</div>
|
||||
searchParams.get('q') && !loading && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
{t.search.resultsFor.replace(':', '')} "{searchParams.get('q')}" {t.search.noResults}
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-500 mt-2">
|
||||
{t.search.tryDifferent}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
export default function Terms() {
|
||||
const router = useRouter();
|
||||
const [lang, setLang] = useState<'ru' | 'en'>('ru');
|
||||
|
||||
const handleLanguageChange = (newLang: 'ru' | 'en') => {
|
||||
setLang(newLang);
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('acceptedTerms', 'true');
|
||||
@@ -12,62 +19,154 @@ export default function Terms() {
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('acceptedTerms', 'false');
|
||||
alert('Вы не можете использовать сайт без согласия с условиями.');
|
||||
alert(lang === 'ru'
|
||||
? 'Вы не можете использовать сайт без согласия с условиями.'
|
||||
: 'You cannot use the site without agreeing to the terms.');
|
||||
};
|
||||
|
||||
const content = {
|
||||
ru: {
|
||||
title: 'Пользовательское соглашение Neo Movies',
|
||||
subtitle: 'Пожалуйста, внимательно ознакомьтесь с условиями использования',
|
||||
selectLanguage: 'Выберите язык / Select Language',
|
||||
accept: 'Принимаю условия',
|
||||
decline: 'Отклонить',
|
||||
footer: '© 2025 Neo Movies. Все права защищены.',
|
||||
sections: [
|
||||
{
|
||||
title: '1. Общие положения',
|
||||
text: 'Использование сайта NeoMovies (https://neo-movies.vercel.app, https://neomovies.ru) возможно только при полном согласии с условиями настоящего Пользовательского соглашения. Несогласие с любыми положениями соглашения означает, что вы не имеете права использовать данный сайт и должны прекратить доступ к нему.'
|
||||
},
|
||||
{
|
||||
title: '2. Описание сервиса',
|
||||
text: 'NeoMovies предоставляет доступ к информации о фильмах и сериалах с использованием API TMDB. Видео воспроизводятся с использованием сторонних видеохостингов и балансеров. Сайт не хранит и не распространяет видеофайлы. Мы выступаем исключительно в роли посредника между пользователем и внешними сервисами.\n\nНекоторая информация о доступности контента также может быть получена из общедоступных децентрализованных источников, включая magnet-ссылки. Сайт не распространяет файлы и не является участником пиринговых сетей.'
|
||||
},
|
||||
{
|
||||
title: '3. Ответственность',
|
||||
text: 'Сайт не несёт ответственности за:',
|
||||
list: [
|
||||
'точность или легальность предоставленного сторонними плеерами контента;',
|
||||
'возможные нарушения авторских прав со стороны балансеров;',
|
||||
'действия пользователей, связанные с просмотром, загрузкой или распространением контента.'
|
||||
],
|
||||
afterList: 'Вся ответственность за использование контента лежит исключительно на пользователе. Использование сторонних источников осуществляется на ваш собственный риск.'
|
||||
},
|
||||
{
|
||||
title: '4. Регистрация и персональные данные',
|
||||
text: 'Сайт собирает только минимальный набор данных: имя, email и пароль — исключительно для сохранения избранного. Пароли шифруются и хранятся безопасно. Мы не передаём ваши данные третьим лицам и не используем их в маркетинговых целях.\n\nИсходный код сайта полностью открыт и доступен для проверки в публичном репозитории, что обеспечивает максимальную прозрачность и возможность независимого аудита безопасности и обработки данных.\n\nПользователь подтверждает, что ему исполнилось 16 лет либо он получил разрешение от законного представителя.'
|
||||
},
|
||||
{
|
||||
title: '5. Изменения в соглашении',
|
||||
text: 'Мы оставляем за собой право вносить изменения в настоящее соглашение. Продолжение использования сервиса после внесения изменений означает ваше согласие с обновлёнными условиями.'
|
||||
},
|
||||
{
|
||||
title: '6. Заключительные положения',
|
||||
text: 'Настоящее соглашение вступает в силу с момента вашего согласия с его условиями и действует бессрочно.\n\nЕсли вы не согласны с какими-либо положениями данного соглашения, вы должны немедленно прекратить использование сервиса.'
|
||||
}
|
||||
]
|
||||
},
|
||||
en: {
|
||||
title: 'Neo Movies Terms of Service',
|
||||
subtitle: 'Please read the terms of use carefully',
|
||||
selectLanguage: 'Select Language / Выберите язык',
|
||||
accept: 'Accept Terms',
|
||||
decline: 'Decline',
|
||||
footer: '© 2025 Neo Movies. All rights reserved.',
|
||||
sections: [
|
||||
{
|
||||
title: '1. General Provisions',
|
||||
text: 'Use of the NeoMovies website (https://neo-movies.vercel.app, https://neomovies.ru) is only possible with full agreement to the terms of this User Agreement. Disagreement with any provisions of the agreement means that you do not have the right to use this site and must stop accessing it.'
|
||||
},
|
||||
{
|
||||
title: '2. Service Description',
|
||||
text: 'NeoMovies provides access to information about movies and TV shows using the TMDB API. Videos are played using third-party video hosting services and load balancers. The site does not store or distribute video files. We act exclusively as an intermediary between the user and external services.\n\nSome information about content availability may also be obtained from publicly available decentralized sources, including magnet links. The site does not distribute files and is not a participant in peer-to-peer networks.'
|
||||
},
|
||||
{
|
||||
title: '3. Liability',
|
||||
text: 'The site is not responsible for:',
|
||||
list: [
|
||||
'the accuracy or legality of content provided by third-party players;',
|
||||
'possible copyright violations by load balancers;',
|
||||
'user actions related to viewing, downloading, or distributing content.'
|
||||
],
|
||||
afterList: 'All responsibility for using the content lies solely with the user. Use of third-party sources is at your own risk.'
|
||||
},
|
||||
{
|
||||
title: '4. Registration and Personal Data',
|
||||
text: 'The site collects only a minimal set of data: name, email, and password — exclusively for saving favorites. Passwords are encrypted and stored securely. We do not share your data with third parties and do not use it for marketing purposes.\n\nThe site\'s source code is fully open and available for review in a public repository, ensuring maximum transparency and the ability for independent security and data processing audits.\n\nThe user confirms that they are at least 16 years old or have received permission from a legal guardian.'
|
||||
},
|
||||
{
|
||||
title: '5. Changes to the Agreement',
|
||||
text: 'We reserve the right to make changes to this agreement. Continued use of the service after changes are made means your acceptance of the updated terms.'
|
||||
},
|
||||
{
|
||||
title: '6. Final Provisions',
|
||||
text: 'This agreement comes into effect from the moment you agree to its terms and is valid indefinitely.\n\nIf you do not agree with any provisions of this agreement, you must immediately stop using the service.'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const t = content[lang];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-background text-foreground">
|
||||
<div className="w-full max-w-4xl bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="mb-8 flex items-center justify-center gap-4">
|
||||
<Globe className="w-5 h-5 text-accent" />
|
||||
<h3 className="text-lg font-semibold text-foreground">{t.selectLanguage}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-8 max-w-md mx-auto">
|
||||
<button
|
||||
onClick={() => handleLanguageChange('ru')}
|
||||
className={`rounded-lg p-3 border-2 transition-all text-center ${
|
||||
lang === 'ru'
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold text-foreground">Русский</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`rounded-lg p-3 border-2 transition-all text-center ${
|
||||
lang === 'en'
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold text-foreground">English</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">Пользовательское соглашение Neo Movies</h1>
|
||||
<p className="mt-2 text-muted-foreground">Пожалуйста, внимательно ознакомьтесь с условиями использования</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-sm sm:prose-base dark:prose-invert max-w-none space-y-4 text-muted-foreground">
|
||||
<p>Благодарим вас за интерес к сервису Neo Movies. Пожалуйста, ознакомьтесь с нашими условиями использования перед началом работы.</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">1. Общие положения</h2>
|
||||
<p>Использование сайта NeoMovies (<a href="https://neo-movies.vercel.app" target="_blank" className="text-accent hover:underline">https://neo-movies.vercel.app</a>), (<a href="https://neomovies.ru" target="_blank" className="text-accent hover:underline">https://neomovies.ru</a>) возможно только при полном согласии с условиями настоящего Пользовательского соглашения. Несогласие с любыми положениями соглашения означает, что вы не имеете права использовать данный сайт и должны прекратить доступ к нему.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">2. Описание сервиса</h2>
|
||||
<p>NeoMovies предоставляет доступ к информации о фильмах и сериалах с использованием API TMDB. Видео воспроизводятся с использованием сторонних видеохостингов и балансеров. Сайт <strong>не хранит и не распространяет</strong> видеофайлы. Мы выступаем исключительно в роли посредника между пользователем и внешними сервисами.</p>
|
||||
<p>Некоторая информация о доступности контента также может быть получена из общедоступных децентрализованных источников, включая magnet-ссылки. Сайт не распространяет файлы и не является участником пиринговых сетей.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">3. Ответственность</h2>
|
||||
<p>Сайт не несёт ответственности за:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>точность или легальность предоставленного сторонними плеерами контента;</li>
|
||||
<li>возможные нарушения авторских прав со стороны балансеров;</li>
|
||||
<li>действия пользователей, связанные с просмотром, загрузкой или распространением контента.</li>
|
||||
</ul>
|
||||
<p>Вся ответственность за использование контента лежит исключительно на пользователе. Использование сторонних источников осуществляется на ваш собственный риск.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">4. Регистрация и персональные данные</h2>
|
||||
<p>Сайт собирает только минимальный набор данных: имя, email и пароль — исключительно для сохранения избранного. Пароли шифруются и хранятся безопасно. Мы не передаём ваши данные третьим лицам и не используем их в маркетинговых целях.</p>
|
||||
<p>Исходный код сайта полностью открыт и доступен для проверки в <a href="https://gitlab.com/foxixus/neomovies" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">публичном репозитории</a>, что обеспечивает максимальную прозрачность и возможность независимого аудита безопасности и обработки данных.</p>
|
||||
<p>Пользователь подтверждает, что ему <strong>исполнилось 16 лет</strong> либо он получил разрешение от законного представителя.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">5. Изменения в соглашении</h2>
|
||||
<p>Мы оставляем за собой право вносить изменения в настоящее соглашение. Продолжение использования сервиса после внесения изменений означает ваше согласие с обновлёнными условиями.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">6. Заключительные положения</h2>
|
||||
<p>Настоящее соглашение вступает в силу с момента вашего согласия с его условиями и действует бессрочно.</p>
|
||||
<p>Если вы не согласны с какими-либо положениями данного соглашения, вы должны немедленно прекратить использование сервиса.</p>
|
||||
</section>
|
||||
{t.sections.map((section, index) => (
|
||||
<section key={index}>
|
||||
<h2 className="text-xl font-semibold text-foreground">{section.title}</h2>
|
||||
{section.text.split('\n\n').map((paragraph, pIndex) => (
|
||||
<p key={pIndex}>{paragraph}</p>
|
||||
))}
|
||||
{section.list && (
|
||||
<>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{section.list.map((item, liIndex) => (
|
||||
<li key={liIndex}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
{section.afterList && <p>{section.afterList}</p>}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -77,20 +176,20 @@ export default function Terms() {
|
||||
onClick={handleDecline}
|
||||
className="px-6 py-3 border border-warm-200 dark:border-warm-700 text-base font-medium rounded-lg bg-white dark:bg-warm-800 hover:bg-warm-100 dark:hover:bg-warm-700 text-foreground transition-colors"
|
||||
>
|
||||
Отклонить
|
||||
{t.decline}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-accent hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent"
|
||||
>
|
||||
Принимаю условия
|
||||
{t.accept}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-8 text-center text-sm text-muted-foreground">
|
||||
<p>© 2025 Neo Movies. Все права защищены.</p>
|
||||
<p>{t.footer}</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
44
src/app/tv/[...slug]/page.tsx
Normal file
44
src/app/tv/[...slug]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
import TVPage from '../_components/TVPage';
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const slugParam = params.slug as string[] | undefined;
|
||||
const [show, setShow] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sourceId = (() => {
|
||||
if (!slugParam || slugParam.length === 0) return '';
|
||||
if (slugParam.length === 1) return slugParam[0]; // new: tmdb_1399
|
||||
if (slugParam.length >= 2) return `${slugParam[0]}_${slugParam[1]}`; // old: tmdb/1399
|
||||
return '';
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchShow() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await tvShowsAPI.getTVBySourceId(sourceId);
|
||||
setShow(response.data);
|
||||
} catch (err: any) {
|
||||
setError(`${t.common.failedToLoad}: ${err?.message || t.common.unknownError}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (sourceId) fetchShow();
|
||||
}, [sourceId]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><Loader2 className="w-12 h-12 animate-spin text-accent" /></div>;
|
||||
if (error || !show) return <div className="flex items-center justify-center min-h-screen"><p>{error || t.common.tvNotFound}</p></div>;
|
||||
|
||||
return <TVPage showId={show.id} show={show} />;
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
import type { TVShowDetails } from '@/lib/neoApi';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import Reactions from '@/components/Reactions';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
import { NextSeo } from 'next-seo';
|
||||
|
||||
interface TVContentProps {
|
||||
showId: string;
|
||||
initialShow: TVShowDetails;
|
||||
}
|
||||
|
||||
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||
const [show] = useState<TVShowDetails>(initialShow);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
const data = await tvShowsAPI.getExternalIds(showId);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
if (initialShow.external_ids?.imdb_id) {
|
||||
setImdbId(initialShow.external_ids.imdb_id);
|
||||
} else {
|
||||
fetchExternalIds();
|
||||
}
|
||||
}, [showId, initialShow.external_ids]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`${show.name} смотреть онлайн`}
|
||||
description={show.overview?.slice(0, 150)}
|
||||
canonical={`https://neomovies.ru/tv/${show.id}`}
|
||||
openGraph={{
|
||||
url: `https://neomovies.ru/tv/${show.id}`,
|
||||
images: [
|
||||
{
|
||||
url: getImageUrl(show.poster_path, 'w780'),
|
||||
alt: show.name,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
{/* schema.org TVSeries */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TVSeries',
|
||||
name: show.name,
|
||||
image: getImageUrl(show.poster_path, 'w780'),
|
||||
description: show.overview,
|
||||
numberOfSeasons: show.number_of_seasons,
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: show.vote_average,
|
||||
ratingCount: show.vote_count,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(show.poster_path, 'w500')}
|
||||
alt={`Постер сериала ${show.name}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{show.name}
|
||||
</h1>
|
||||
{show.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{show.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">Рейтинг: {show.vote_average.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">Сезонов: {show.number_of_seasons}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{formatDate(show.first_air_date)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.genres.map((genre) => (
|
||||
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||
<p>{show.overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{imdbId && (
|
||||
<button
|
||||
onClick={handleOpenPlayer}
|
||||
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||
>
|
||||
<PlayCircle size={20} />
|
||||
<span>Смотреть</span>
|
||||
</button>
|
||||
)}
|
||||
<FavoriteButton
|
||||
mediaId={show.id.toString()}
|
||||
mediaType="tv"
|
||||
title={show.name}
|
||||
posterPath={show.poster_path}
|
||||
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Reactions mediaId={show.id.toString()} mediaType="tv" />
|
||||
</div>
|
||||
|
||||
{imdbId && (
|
||||
<div className="mt-10 space-y-4">
|
||||
<div id="movie-player" className="hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||
<MoviePlayer
|
||||
id={show.id.toString()}
|
||||
title={show.name}
|
||||
poster={show.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
</div>
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="tv"
|
||||
title={show.name}
|
||||
originalTitle={show.original_name}
|
||||
year={show.first_air_date?.split('-')[0]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlayerFullscreen && imdbId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<MoviePlayer
|
||||
id={show.id.toString()}
|
||||
title={show.name}
|
||||
poster={show.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Metadata } from 'next';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
import TVPage from '@/app/tv/[id]/TVPage';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||
const { params } = await props;
|
||||
try {
|
||||
const showId = params.id;
|
||||
const { data: show } = await tvShowsAPI.getTVShow(showId);
|
||||
|
||||
return {
|
||||
title: `${show.name} - NeoMovies`,
|
||||
description: show.overview,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating metadata:', error);
|
||||
return {
|
||||
title: 'Сериал - NeoMovies',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getData(id: string) {
|
||||
try {
|
||||
const { data: show } = await tvShowsAPI.getTVShow(id);
|
||||
return { id, show };
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch TV show');
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = params;
|
||||
const data = await getData(id);
|
||||
return <TVPage showId={data.id} show={data.show} />;
|
||||
}
|
||||
325
src/app/tv/_components/TVContent.tsx
Normal file
325
src/app/tv/_components/TVContent.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
import { getImageUrl as getImageUrlOriginal } from '@/lib/neoApi';
|
||||
import type { TVShowDetails } from '@/lib/neoApi';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import TorrentSelector from '@/components/TorrentSelector';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import Reactions from '@/components/Reactions';
|
||||
import PlayerSelector from '@/components/PlayerSelector';
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { useSettings, getAvailablePlayers } from '@/hooks/useSettings';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
import { unifyMovieData, formatRating, getImageUrl } from '@/lib/dataUtils';
|
||||
|
||||
interface TVContentProps {
|
||||
showId: string;
|
||||
initialShow: TVShowDetails;
|
||||
}
|
||||
|
||||
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const [show] = useState<TVShowDetails>(initialShow);
|
||||
const unified = unifyMovieData(show);
|
||||
const [externalIds, setExternalIds] = useState<any>(null);
|
||||
const [imdbId, setImdbId] = useState<string | null>(unified.imdbId || null);
|
||||
const [kinopoiskId, setKinopoiskId] = useState<number | null>(unified.kinopoiskId || null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string>(settings.defaultPlayer);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||
const [selectedEpisode, setSelectedEpisode] = useState<number>(1);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showEpisodeSelector = selectedPlayer !== 'lumex' && selectedPlayer !== 'vibix' && selectedPlayer !== 'hdvb';
|
||||
|
||||
// Sync selectedPlayer with settings
|
||||
useEffect(() => {
|
||||
setSelectedPlayer(settings.defaultPlayer);
|
||||
}, [settings.defaultPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('TV Show data:', show);
|
||||
console.log('Unified data:', unified);
|
||||
|
||||
// Check if we have unified data with externalIds
|
||||
if (show.externalIds) {
|
||||
console.log('Found externalIds in unified data:', show.externalIds);
|
||||
setExternalIds(show.externalIds);
|
||||
if (show.externalIds.imdb) {
|
||||
console.log('Found imdb_id in externalIds:', show.externalIds.imdb);
|
||||
setImdbId(show.externalIds.imdb);
|
||||
}
|
||||
if (show.externalIds.kp) {
|
||||
setKinopoiskId(show.externalIds.kp);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (show.imdb_id) {
|
||||
setImdbId(show.imdb_id);
|
||||
}
|
||||
if (show.kinopoisk_id) {
|
||||
setKinopoiskId(show.kinopoisk_id);
|
||||
}
|
||||
if (initialShow.external_ids?.imdb_id) {
|
||||
setImdbId(initialShow.external_ids.imdb_id);
|
||||
}
|
||||
if (initialShow.external_ids?.kinopoisk_id) {
|
||||
setKinopoiskId(initialShow.external_ids.kinopoisk_id);
|
||||
}
|
||||
|
||||
if (!imdbId || !kinopoiskId) {
|
||||
const fetchExternalIds = async () => {
|
||||
try {
|
||||
console.log('Fetching external IDs for TV show:', showId);
|
||||
const data = await tvShowsAPI.getExternalIds(showId);
|
||||
console.log('External IDs response:', data);
|
||||
setExternalIds(data);
|
||||
if (data?.imdb_id && !imdbId) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
if (data?.kinopoisk_id && !kinopoiskId) {
|
||||
setKinopoiskId(data.kinopoisk_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching external ids:', err);
|
||||
}
|
||||
};
|
||||
fetchExternalIds();
|
||||
}
|
||||
}, [showId, initialShow.external_ids, show.imdb_id, show.kinopoisk_id]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`${unified.title} ${t.details.watchOnline}`}
|
||||
description={unified.overview?.slice(0, 150)}
|
||||
canonical={`https://neomovies.ru/tv/${unified.id}`}
|
||||
openGraph={{
|
||||
url: `https://neomovies.ru/tv/${unified.id}`,
|
||||
images: [
|
||||
{
|
||||
url: getImageUrl(unified.posterPath, 'w780'),
|
||||
alt: unified.title,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TVSeries',
|
||||
name: unified.title,
|
||||
image: getImageUrl(unified.posterPath, 'w780'),
|
||||
description: unified.overview,
|
||||
numberOfSeasons: show.number_of_seasons,
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: unified.voteAverage,
|
||||
ratingCount: unified.voteCount,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(unified.posterPath, 'w500')}
|
||||
alt={unified.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{unified.title}
|
||||
</h1>
|
||||
{unified.originalTitle && unified.originalTitle !== unified.title && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{unified.originalTitle}</p>
|
||||
)}
|
||||
{show.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{show.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">{t.details.rating}: {formatRating(unified.voteAverage)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">Сезонов: {show.number_of_seasons}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{unified.year}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{unified.genres.filter(g => g.name).map((genre, idx) => (
|
||||
<span key={idx} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||
<p>{unified.overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{(imdbId || kinopoiskId) && (
|
||||
<button
|
||||
onClick={handleOpenPlayer}
|
||||
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||
>
|
||||
<PlayCircle size={20} />
|
||||
<span>{t.details.watchNow}</span>
|
||||
</button>
|
||||
)}
|
||||
<FavoriteButton
|
||||
mediaId={unified.id.toString()}
|
||||
mediaType="tv"
|
||||
title={unified.title}
|
||||
posterPath={unified.posterPath}
|
||||
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Reactions
|
||||
mediaId={externalIds?.tmdb ? externalIds.tmdb.toString() : unified.id.toString()}
|
||||
mediaType="tv"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(imdbId || kinopoiskId) && (
|
||||
<div className="mt-10 space-y-4">
|
||||
<div id="movie-player" className="rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||
<PlayerSelector
|
||||
language={settings.language}
|
||||
selectedPlayer={selectedPlayer}
|
||||
onPlayerChange={setSelectedPlayer}
|
||||
/>
|
||||
{showEpisodeSelector && (
|
||||
<EpisodeSelector
|
||||
seasons={show.number_of_seasons || 1}
|
||||
selectedSeason={selectedSeason}
|
||||
selectedEpisode={selectedEpisode}
|
||||
onSeasonChange={setSelectedSeason}
|
||||
onEpisodeChange={setSelectedEpisode}
|
||||
/>
|
||||
)}
|
||||
<MoviePlayer
|
||||
id={unified.id.toString()}
|
||||
title={unified.title}
|
||||
poster={unified.posterPath || ''}
|
||||
imdbId={imdbId || undefined}
|
||||
kinopoiskId={kinopoiskId?.toString()}
|
||||
selectedPlayer={selectedPlayer}
|
||||
season={selectedSeason}
|
||||
episode={selectedEpisode}
|
||||
/>
|
||||
</div>
|
||||
{imdbId && (
|
||||
<TorrentSelector
|
||||
imdbId={imdbId}
|
||||
type="tv"
|
||||
title={unified.title}
|
||||
originalTitle={unified.originalTitle}
|
||||
year={unified.year}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlayerFullscreen && (imdbId || kinopoiskId) && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<div className={`absolute top-4 left-4 z-50 transition-opacity duration-300 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/75"
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`w-full px-4 pt-16 pb-4 space-y-3 transition-opacity duration-300 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<PlayerSelector
|
||||
language={settings.language}
|
||||
selectedPlayer={selectedPlayer}
|
||||
onPlayerChange={setSelectedPlayer}
|
||||
/>
|
||||
{showEpisodeSelector && (
|
||||
<EpisodeSelector
|
||||
seasons={show.number_of_seasons || 1}
|
||||
selectedSeason={selectedSeason}
|
||||
selectedEpisode={selectedEpisode}
|
||||
onSeasonChange={setSelectedSeason}
|
||||
onEpisodeChange={setSelectedEpisode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full flex items-center justify-center px-4">
|
||||
<MoviePlayer
|
||||
id={unified.id.toString()}
|
||||
title={unified.title}
|
||||
poster={unified.posterPath || ''}
|
||||
imdbId={imdbId || undefined}
|
||||
kinopoiskId={kinopoiskId?.toString()}
|
||||
isFullscreen={true}
|
||||
selectedPlayer={selectedPlayer}
|
||||
season={selectedSeason}
|
||||
episode={selectedEpisode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import TVContent from '@/app/tv/[id]/TVContent';
|
||||
import TVContent from './TVContent';
|
||||
import type { TVShowDetails } from '@/lib/neoApi';
|
||||
|
||||
interface TVPageProps {
|
||||
1
src/app/tv/page.tsx
Normal file
1
src/app/tv/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function Page(){ return null; }
|
||||
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authAPI } from '../../lib/authApi';
|
||||
import { useTranslation } from '@/contexts/TranslationContext';
|
||||
|
||||
export default function VerificationClient() {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
@@ -41,12 +43,12 @@ export default function VerificationClient() {
|
||||
|
||||
try {
|
||||
if (!email) {
|
||||
throw new Error('Не удалось получить email для подтверждения');
|
||||
throw new Error(t.verify.emailError);
|
||||
}
|
||||
await verifyCode(code);
|
||||
router.replace('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
||||
setError(err instanceof Error ? err.message : t.common.error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -62,7 +64,7 @@ export default function VerificationClient() {
|
||||
await authAPI.resendCode(email);
|
||||
setCountdown(60);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось отправить код');
|
||||
setError(err instanceof Error ? err.message : t.verify.resendFailed);
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
@@ -70,9 +72,9 @@ export default function VerificationClient() {
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
|
||||
<h2 className="text-xl font-bold text-center mb-2 text-foreground">Подтверждение email</h2>
|
||||
<h2 className="text-xl font-bold text-center mb-2 text-foreground">{t.verify.title}</h2>
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Мы отправили код подтверждения на {email}
|
||||
{t.verify.sentCode} {email}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user