mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-27 17:38:50 +05:00
Release 2.5
This commit is contained in:
12
.env.example
12
.env.example
@@ -1,5 +1,11 @@
|
|||||||
# API URL для нового Go API
|
|
||||||
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
|
NEXT_PUBLIC_API_URL=https://api.neomovies.ru
|
||||||
|
|
||||||
# Для локальной разработки используйте:
|
MONGODB_URI=mongodb://localhost:27017/neomovies
|
||||||
# NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
||||||
|
NEXTAUTH_SECRET=your_nextauth_secret_here
|
||||||
|
NEXTAUTH_URL=http://localhost:3001
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=your_google_client_id
|
||||||
|
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||||
|
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://neomovies.ru
|
||||||
|
|||||||
@@ -187,5 +187,5 @@ Users are advised to verify whether use of the site complies with their local co
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<p>Made with <3 by Foxix/Erno</p>
|
<p>Made with ❤️ by Foxix</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
netlify.toml
Normal file
7
netlify.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = ".next"
|
||||||
|
functions = "netlify/functions"
|
||||||
|
|
||||||
|
[[plugins]]
|
||||||
|
package = "@netlify/plugin-nextjs"
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neo-movies-web",
|
"name": "neo-movies-web",
|
||||||
"version": "0.1.0",
|
"version": "2.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neo-movies-web",
|
"name": "neo-movies-web",
|
||||||
"version": "0.1.0",
|
"version": "2.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "neo-movies-web",
|
"name": "neo-movies-web",
|
||||||
"version": "0.1.0",
|
"version": "2.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||||
<url><loc>https://neomovies.ru/auth/callback</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/auth/callback</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/admin/login</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/categories</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/categories</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/admin/login</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/login</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/favorites</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/favorites</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/login</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/movie</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/profile</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/profile</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/search</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/settings</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/search</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/verify</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/settings</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://neomovies.ru/terms</loc><lastmod>2025-08-08T16:29:17.862Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://neomovies.ru/tv</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/verify</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
|
<url><loc>https://neomovies.ru/terms</loc><lastmod>2025-10-19T13:40:08.554Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
@@ -3,38 +3,60 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { categoriesAPI, Movie } from '@/lib/neoApi';
|
import { categoriesAPI, Movie } from '@/lib/neoApi';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
import MovieCard from '@/components/MovieCard';
|
import MovieCard from '@/components/MovieCard';
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
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() {
|
function CategoryPage() {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const categoryId = parseInt(params.id as string);
|
const categoryId = parseInt(params.id as string);
|
||||||
|
|
||||||
const [categoryName, setCategoryName] = useState<string>('');
|
const [categoryName, setCategoryName] = useState<string>('');
|
||||||
const [mediaType, setMediaType] = useState<MediaType>('movies');
|
const [movies, setMovies] = useState<Movie[]>([]);
|
||||||
const [items, setItems] = useState<Movie[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [moviesAvailable, setMoviesAvailable] = useState(true);
|
|
||||||
const [tvShowsAvailable, setTvShowsAvailable] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCategoryName() {
|
// Устанавливаем название категории из переводов
|
||||||
try {
|
if (categoryId && t.categories.names[categoryId as keyof typeof t.categories.names]) {
|
||||||
const response = await categoriesAPI.getCategory(categoryId);
|
setCategoryName(t.categories.names[categoryId as keyof typeof t.categories.names]);
|
||||||
setCategoryName(response.data.name);
|
} else {
|
||||||
} catch (error) {
|
setCategoryName(t.categories.unknownCategory);
|
||||||
console.error('Error fetching category:', error);
|
|
||||||
setError('Не удалось загрузить информацию о категории');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (categoryId) {
|
|
||||||
fetchCategoryName();
|
|
||||||
}
|
}
|
||||||
}, [categoryId]);
|
}, [categoryId]);
|
||||||
|
|
||||||
@@ -46,47 +68,27 @@ function CategoryPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
// Выбираем источник по языку: ru -> kp, иначе tmdb
|
||||||
if (mediaType === 'movies') {
|
const source = (locale || 'ru') === 'ru' ? 'kp' : 'tmdb';
|
||||||
response = await categoriesAPI.getMoviesByCategory(categoryId, page);
|
// Получаем и фильмы, и сериалы, объединяем, чтобы не было пустых категорий
|
||||||
const hasMovies = response.data.results.length > 0;
|
const [tvRes, movieRes]: any = await Promise.all([
|
||||||
if (page === 1) setMoviesAvailable(hasMovies);
|
categoriesAPI.getMediaByCategory(categoryId, 'tv', page, undefined, source, t.categories?.names?.[categoryId as any] ?? ''),
|
||||||
setItems(response.data.results);
|
categoriesAPI.getMediaByCategory(categoryId, 'movie', page, undefined, source, t.categories?.names?.[categoryId as any] ?? '')
|
||||||
setTotalPages(response.data.total_pages);
|
]);
|
||||||
if (!hasMovies && tvShowsAvailable && page === 1) {
|
const tvItems = tvRes?.results || [];
|
||||||
setMediaType('tv');
|
const movieItems = movieRes?.results || [];
|
||||||
}
|
const combined = [...tvItems, ...movieItems];
|
||||||
} else {
|
setMovies(combined);
|
||||||
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
setTotalPages(Math.max(tvRes?.total_pages || 1, movieRes?.total_pages || 1));
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Ошибка при загрузке данных');
|
setError(t.categories.errorLoadingMovies);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [categoryId, mediaType, page, moviesAvailable, tvShowsAvailable]);
|
}, [categoryId, page]);
|
||||||
|
|
||||||
const handleMediaTypeChange = (type: MediaType) => {
|
|
||||||
if (type === 'movies' && !moviesAvailable) return;
|
|
||||||
if (type === 'tv' && !tvShowsAvailable) return;
|
|
||||||
setMediaType(type);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
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">
|
<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">
|
<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} />
|
<ArrowLeft size={16} />
|
||||||
Назад к категориям
|
{t.common.backToCategories}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
|
<div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
|
||||||
{error}
|
{error}
|
||||||
@@ -182,45 +184,28 @@ function CategoryPage() {
|
|||||||
Назад к категориям
|
Назад к категориям
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
|
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground capitalize">
|
||||||
{categoryName || 'Загрузка...'}
|
{categoryName}
|
||||||
</h1>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center min-h-[40vh]">
|
<div className="flex justify-center items-center min-h-[40vh]">
|
||||||
<Loader2 className="w-12 h-12 animate-spin text-accent" />
|
<Loader2 className="w-12 h-12 animate-spin text-accent" />
|
||||||
</div>
|
</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">
|
<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
|
<MovieCard
|
||||||
key={`${mediaType}-${item.id}-${page}`}
|
key={`movie-${movie.id}-${page}`}
|
||||||
movie={item}
|
movie={movie}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
<p>Нет {mediaType === 'movies' ? 'фильмов' : 'сериалов'} в этой категории.</p>
|
<p>{t.categories.noMoviesInCategory}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { categoriesAPI, Category } from '@/lib/neoApi';
|
import { categoriesAPI, Category } from '@/lib/neoApi';
|
||||||
import CategoryCard from '@/components/CategoryCard';
|
import CategoryCard from '@/components/CategoryCard';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
interface CategoryWithBackground extends Category {
|
interface CategoryWithBackground extends Category {
|
||||||
backgroundUrl?: string | null;
|
backgroundUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoriesPage() {
|
function CategoriesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [categories, setCategories] = useState<CategoryWithBackground[]>([]);
|
const [categories, setCategories] = useState<CategoryWithBackground[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -21,46 +23,24 @@ function CategoriesPage() {
|
|||||||
try {
|
try {
|
||||||
const categoriesResponse = await categoriesAPI.getCategories();
|
const categoriesResponse = await categoriesAPI.getCategories();
|
||||||
|
|
||||||
if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) {
|
if (!categoriesResponse.data || categoriesResponse.data.length === 0) {
|
||||||
setError('Не удалось загрузить категории');
|
setError('Не удалось загрузить категории');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all(
|
||||||
categoriesResponse.data.categories.map(async (category: Category) => {
|
categoriesResponse.data.map(async (category: Category) => {
|
||||||
try {
|
try {
|
||||||
const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1);
|
// Пробуем получить сериал как фон, иначе фильм
|
||||||
|
const tvRes: any = await categoriesAPI.getMediaByCategory(category.id, 'tv', 1);
|
||||||
if (moviesResponse.data.results && moviesResponse.data.results.length > 0) {
|
const movieRes: any = await categoriesAPI.getMediaByCategory(category.id, 'movie', 1);
|
||||||
const backgroundUrl = moviesResponse.data.results[0].backdrop_path ||
|
const pick = tvRes?.results?.[0] || movieRes?.results?.[0];
|
||||||
moviesResponse.data.results[0].poster_path;
|
const backgroundUrl = pick?.backdrop_path || pick?.poster_path || null;
|
||||||
|
return { ...category, backgroundUrl };
|
||||||
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
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching background for category ${category.id}:`, 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);
|
setCategories(categoriesWithBackgrounds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching categories:', error);
|
console.error('Error fetching categories:', error);
|
||||||
setError('Ошибка при загрузке категорий');
|
setError(t.categories.errorLoadingCategories);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Image from 'next/image';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { neoApi, getImageUrl } from '@/lib/neoApi';
|
import { neoApi, getImageUrl } from '@/lib/neoApi';
|
||||||
import { Loader2, HeartCrack } from 'lucide-react';
|
import { Loader2, HeartCrack } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
interface Favorite {
|
interface Favorite {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +17,7 @@ interface Favorite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FavoritesPage() {
|
export default function FavoritesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,16 +32,14 @@ export default function FavoritesPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await neoApi.get('/api/v1/favorites');
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to fetch favorites:', error);
|
console.error('Failed to fetch favorites:', error);
|
||||||
// Редиректим только при явном 401
|
// 401 ошибки теперь обрабатываются автоматически через interceptor
|
||||||
if (error?.response?.status === 401) {
|
// Здесь просто устанавливаем пустой массив при ошибке
|
||||||
localStorage.removeItem('token');
|
setFavorites([]);
|
||||||
localStorage.removeItem('userName');
|
|
||||||
localStorage.removeItem('userEmail');
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,15 +61,15 @@ export default function FavoritesPage() {
|
|||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<HeartCrack size={80} className="mx-auto mb-6 text-gray-400" />
|
<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">
|
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||||
У вас пока нет избранных фильмов и сериалов
|
{t.favorites.emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,7 @@ export default function FavoritesPage() {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-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 className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
Ваша коллекция любимых фильмов и сериалов
|
Ваша коллекция любимых фильмов и сериалов
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export default function LoginClient() {
|
export default function LoginClient() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -34,7 +36,7 @@ export default function LoginClient() {
|
|||||||
router.push(`/verify?email=${encodeURIComponent(email)}`);
|
router.push(`/verify?email=${encodeURIComponent(email)}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
setError(err instanceof Error ? err.message : t.common.error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ export default function LoginClient() {
|
|||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Имя"
|
placeholder={t.auth.name}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required={!isLogin}
|
required={!isLogin}
|
||||||
@@ -76,7 +78,7 @@ export default function LoginClient() {
|
|||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Пароль"
|
placeholder={t.auth.password}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -89,7 +91,7 @@ export default function LoginClient() {
|
|||||||
disabled={isLoading}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ export default function LoginClient() {
|
|||||||
<div className="w-full border-t border-warm-200 dark:border-warm-700"></div>
|
<div className="w-full border-t border-warm-200 dark:border-warm-700"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<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>
|
||||||
</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"
|
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} />
|
<Image src="/google.svg" alt="Google" width={20} height={20} />
|
||||||
Продолжить с Google
|
{t.auth.continueWithGoogle}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -118,13 +120,13 @@ export default function LoginClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
|
<div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
|
||||||
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}{' '}
|
{isLogin ? t.auth.noAccount : t.auth.haveAccount}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsLogin(!isLogin)}
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
className="text-accent hover:underline focus:outline-none"
|
className="text-accent hover:underline focus:outline-none"
|
||||||
>
|
>
|
||||||
{isLogin ? 'Зарегистрироваться' : 'Войти'}
|
{isLogin ? t.auth.registerButton : t.auth.loginButton}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "@/contexts/TranslationContext";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
|
<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">
|
<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>
|
<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
|
<Link
|
||||||
href="/"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { useMovies, MovieCategory } from '@/hooks/useMovies';
|
|||||||
import MovieTile from '@/components/MovieTile';
|
import MovieTile from '@/components/MovieTile';
|
||||||
import Pagination from '@/components/Pagination';
|
import Pagination from '@/components/Pagination';
|
||||||
import HorizontalSlider from '@/components/HorizontalSlider';
|
import HorizontalSlider from '@/components/HorizontalSlider';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
|
const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
|
||||||
const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
|
const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ export default function HomePage() {
|
|||||||
if (loading && !movies.length) {
|
if (loading && !movies.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
|
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
Загрузка...
|
{t.common.loading}
|
||||||
</div>
|
</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'
|
: '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`}
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
>
|
>
|
||||||
Популярные
|
{t.home.popular}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('now-playing')}
|
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'
|
: '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`}
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
>
|
>
|
||||||
Новинки
|
{t.home.nowPlaying}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('top-rated')}
|
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'
|
: '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`}
|
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||||
>
|
>
|
||||||
Топ рейтинга
|
{t.home.topRated}
|
||||||
</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`}
|
|
||||||
>
|
|
||||||
Скоро
|
|
||||||
</button>
|
</button>
|
||||||
|
{/* Удалена вкладка "Скоро" */}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Loader2, User, LogOut, Trash2, ArrowLeft } from 'lucide-react';
|
import { Loader2, User, LogOut, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
@@ -30,12 +32,12 @@ export default function ProfilePage() {
|
|||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await authAPI.deleteAccount();
|
await authAPI.deleteAccount();
|
||||||
toast.success('Аккаунт успешно удален.');
|
toast.success(t.profile.accountDeleted);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
logout();
|
logout();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete account:', error);
|
console.error('Failed to delete account:', error);
|
||||||
toast.error('Не удалось удалить аккаунт. Попробуйте снова.');
|
toast.error(t.profile.deleteFailed);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -57,7 +59,7 @@ export default function ProfilePage() {
|
|||||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
<span>Назад</span>
|
<span>{t.common.back}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8 text-center mb-6">
|
<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>
|
||||||
|
|
||||||
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8 mb-6">
|
<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
|
<button
|
||||||
onClick={logout}
|
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"
|
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} />
|
<LogOut size={20} />
|
||||||
<span>Выйти из аккаунта</span>
|
<span>{t.profile.logout}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-red-500/10 border-2 border-dashed border-red-500/50 rounded-lg p-6 sm:p-8 text-center">
|
<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>
|
<h2 className="text-xl font-bold text-red-500 mb-4">{t.profile.dangerZone}</h2>
|
||||||
<p className="text-red-400 mb-6">Это действие нельзя будет отменить. Все ваши данные, включая избранное, будут удалены.</p>
|
<p className="text-red-400 mb-6">{t.profile.deleteWarning}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
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"
|
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} />
|
<Trash2 size={20} />
|
||||||
<span>Удалить аккаунт</span>
|
<span>{t.profile.deleteAccount}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +97,9 @@ export default function ProfilePage() {
|
|||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="Подтвердите удаление аккаунта"
|
title={t.profile.confirmDelete}
|
||||||
>
|
>
|
||||||
<p>Вы уверены, что хотите навсегда удалить свой аккаунт? Все ваши данные, включая избранное и реакции, будут безвозвратно удалены. Это действие нельзя будет отменить.</p>
|
<p>{t.profile.confirmDeleteText}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,59 +5,191 @@ import { useSearchParams, useRouter } from 'next/navigation';
|
|||||||
import { searchAPI } from '@/lib/neoApi';
|
import { searchAPI } from '@/lib/neoApi';
|
||||||
import type { Movie } from '@/lib/neoApi';
|
import type { Movie } from '@/lib/neoApi';
|
||||||
import MovieCard from '@/components/MovieCard';
|
import MovieCard from '@/components/MovieCard';
|
||||||
|
import type { UnifiedSearchItem } from '@/lib/unifiedTypes';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export default function SearchClient() {
|
export default function SearchClient() {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||||
const [results, setResults] = useState<Movie[]>([]);
|
const [results, setResults] = useState<Movie[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [totalResults, setTotalResults] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentQuery = searchParams.get('q');
|
const currentQuery = searchParams.get('q');
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
|
||||||
if (currentQuery) {
|
if (currentQuery) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
searchAPI.multiSearch(currentQuery)
|
setCurrentPage(page);
|
||||||
.then((response) => {
|
|
||||||
setResults(response.data.results || []);
|
// Выбор источника: 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) => {
|
.catch((error) => {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setTotalResults(0);
|
||||||
|
setTotalPages(0);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
} else {
|
} else {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setTotalResults(0);
|
||||||
|
setTotalPages(0);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
|
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{searchParams.get('q') && (
|
{searchParams.get('q') && (
|
||||||
<h1 className="text-2xl font-bold mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
Результаты поиска для: <span className="text-primary">"{searchParams.get('q')}"</span>
|
<h1 className="text-2xl font-bold mb-2">
|
||||||
</h1>
|
{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 ? (
|
{loading ? (
|
||||||
<div className="text-center">Загрузка...</div>
|
<div className="flex justify-center items-center py-20">
|
||||||
) : results.length > 0 ? (
|
<div className="text-center">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||||
{results.map((item) => (
|
<p>{t.search.loadingResults}</p>
|
||||||
<MovieCard
|
</div>
|
||||||
key={`${item.id}-${item.media_type || 'movie'}`}
|
|
||||||
movie={item}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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') && (
|
searchParams.get('q') && !loading && (
|
||||||
<div className="text-center">Ничего не найдено.</div>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
export default function Terms() {
|
export default function Terms() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [lang, setLang] = useState<'ru' | 'en'>('ru');
|
||||||
|
|
||||||
|
const handleLanguageChange = (newLang: 'ru' | 'en') => {
|
||||||
|
setLang(newLang);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleAccept = () => {
|
||||||
localStorage.setItem('acceptedTerms', 'true');
|
localStorage.setItem('acceptedTerms', 'true');
|
||||||
@@ -12,62 +19,154 @@ export default function Terms() {
|
|||||||
|
|
||||||
const handleDecline = () => {
|
const handleDecline = () => {
|
||||||
localStorage.setItem('acceptedTerms', 'false');
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-background text-foreground">
|
<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">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Пользовательское соглашение Neo Movies</h1>
|
<h1 className="text-3xl font-bold text-foreground">{t.title}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">Пожалуйста, внимательно ознакомьтесь с условиями использования</p>
|
<p className="mt-2 text-muted-foreground">{t.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="prose prose-sm sm:prose-base dark:prose-invert max-w-none space-y-4 text-muted-foreground">
|
<div className="prose prose-sm sm:prose-base dark:prose-invert max-w-none space-y-4 text-muted-foreground">
|
||||||
<p>Благодарим вас за интерес к сервису Neo Movies. Пожалуйста, ознакомьтесь с нашими условиями использования перед началом работы.</p>
|
{t.sections.map((section, index) => (
|
||||||
|
<section key={index}>
|
||||||
<section>
|
<h2 className="text-xl font-semibold text-foreground">{section.title}</h2>
|
||||||
<h2 className="text-xl font-semibold text-foreground">1. Общие положения</h2>
|
{section.text.split('\n\n').map((paragraph, pIndex) => (
|
||||||
<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>
|
<p key={pIndex}>{paragraph}</p>
|
||||||
</section>
|
))}
|
||||||
|
{section.list && (
|
||||||
<section>
|
<>
|
||||||
<h2 className="text-xl font-semibold text-foreground">2. Описание сервиса</h2>
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
<p>NeoMovies предоставляет доступ к информации о фильмах и сериалах с использованием API TMDB. Видео воспроизводятся с использованием сторонних видеохостингов и балансеров. Сайт <strong>не хранит и не распространяет</strong> видеофайлы. Мы выступаем исключительно в роли посредника между пользователем и внешними сервисами.</p>
|
{section.list.map((item, liIndex) => (
|
||||||
<p>Некоторая информация о доступности контента также может быть получена из общедоступных децентрализованных источников, включая magnet-ссылки. Сайт не распространяет файлы и не является участником пиринговых сетей.</p>
|
<li key={liIndex}>{item}</li>
|
||||||
</section>
|
))}
|
||||||
|
</ul>
|
||||||
<section>
|
{section.afterList && <p>{section.afterList}</p>}
|
||||||
<h2 className="text-xl font-semibold text-foreground">3. Ответственность</h2>
|
</>
|
||||||
<p>Сайт не несёт ответственности за:</p>
|
)}
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
</section>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -77,20 +176,20 @@ export default function Terms() {
|
|||||||
onClick={handleDecline}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleAccept}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-8 text-center text-sm text-muted-foreground">
|
<footer className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
<p>© 2025 Neo Movies. Все права защищены.</p>
|
<p>{t.footer}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import TVContent from '@/app/tv/[id]/TVContent';
|
import TVContent from './TVContent';
|
||||||
import type { TVShowDetails } from '@/lib/neoApi';
|
import type { TVShowDetails } from '@/lib/neoApi';
|
||||||
|
|
||||||
interface TVPageProps {
|
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 { useAuth } from '../../hooks/useAuth';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { authAPI } from '../../lib/authApi';
|
import { authAPI } from '../../lib/authApi';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export default function VerificationClient() {
|
export default function VerificationClient() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [countdown, setCountdown] = useState(60);
|
const [countdown, setCountdown] = useState(60);
|
||||||
@@ -41,12 +43,12 @@ export default function VerificationClient() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error('Не удалось получить email для подтверждения');
|
throw new Error(t.verify.emailError);
|
||||||
}
|
}
|
||||||
await verifyCode(code);
|
await verifyCode(code);
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
setError(err instanceof Error ? err.message : t.common.error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ export default function VerificationClient() {
|
|||||||
await authAPI.resendCode(email);
|
await authAPI.resendCode(email);
|
||||||
setCountdown(60);
|
setCountdown(60);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Не удалось отправить код');
|
setError(err instanceof Error ? err.message : t.verify.resendFailed);
|
||||||
} finally {
|
} finally {
|
||||||
setIsResending(false);
|
setIsResending(false);
|
||||||
}
|
}
|
||||||
@@ -70,9 +72,9 @@ export default function VerificationClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
|
<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">
|
<p className="text-muted-foreground text-center mb-8">
|
||||||
Мы отправили код подтверждения на {email}
|
{t.verify.sentCode} {email}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Category } from '@/lib/neoApi';
|
import { Category, getImageUrl } from '@/lib/neoApi';
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
@@ -48,7 +48,9 @@ function getCategoryColor(categoryId: number): string {
|
|||||||
|
|
||||||
function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
|
function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [imageUrl] = useState<string>(backgroundUrl || '/images/placeholder.jpg');
|
const [imageUrl] = useState<string>(
|
||||||
|
backgroundUrl ? getImageUrl(backgroundUrl, 'w780') : '/images/placeholder.jpg'
|
||||||
|
);
|
||||||
|
|
||||||
const categoryColor = getCategoryColor(category.id);
|
const categoryColor = getCategoryColor(category.id);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import HeaderBar from './HeaderBar';
|
import HeaderBar from './HeaderBar';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { ThemeProvider } from './ThemeProvider';
|
import { ThemeProvider } from './ThemeProvider';
|
||||||
|
import { TranslationProvider } from '@/contexts/TranslationContext';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import MobileNav from './MobileNav';
|
import MobileNav from './MobileNav';
|
||||||
|
|
||||||
@@ -28,12 +29,14 @@ export function ClientLayout({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||||
<div className="flex flex-col min-h-screen">
|
<TranslationProvider>
|
||||||
<HeaderBar onBurgerClick={() => setIsMobileMenuOpen(true)} />
|
<div className="flex flex-col min-h-screen">
|
||||||
<MobileNav show={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
|
<HeaderBar onBurgerClick={() => setIsMobileMenuOpen(true)} />
|
||||||
<main className="flex-1 w-full">{children}</main>
|
<MobileNav show={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
|
||||||
<Toaster position="bottom-right" />
|
<main className="flex-1 w-full">{children}</main>
|
||||||
</div>
|
<Toaster position="bottom-right" />
|
||||||
|
</div>
|
||||||
|
</TranslationProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/components/EpisodeSelector.tsx
Normal file
111
src/components/EpisodeSelector.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
|
interface EpisodeSelectorProps {
|
||||||
|
seasons: number;
|
||||||
|
selectedSeason: number;
|
||||||
|
selectedEpisode: number;
|
||||||
|
onSeasonChange: (season: number) => void;
|
||||||
|
onEpisodeChange: (episode: number) => void;
|
||||||
|
episodesPerSeason?: number; // Default episodes per season
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodeSelector({
|
||||||
|
seasons,
|
||||||
|
selectedSeason,
|
||||||
|
selectedEpisode,
|
||||||
|
onSeasonChange,
|
||||||
|
onEpisodeChange,
|
||||||
|
episodesPerSeason = 24
|
||||||
|
}: EpisodeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [seasonOpen, setSeasonOpen] = useState(false);
|
||||||
|
const [episodeOpen, setEpisodeOpen] = useState(false);
|
||||||
|
const seasonRef = useRef<HTMLDivElement>(null);
|
||||||
|
const episodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (seasonRef.current && !seasonRef.current.contains(event.target as Node)) {
|
||||||
|
setSeasonOpen(false);
|
||||||
|
}
|
||||||
|
if (episodeRef.current && !episodeRef.current.contains(event.target as Node)) {
|
||||||
|
setEpisodeOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seasonList = Array.from({ length: seasons }, (_, i) => i + 1);
|
||||||
|
const episodeList = Array.from({ length: episodesPerSeason }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
{/* Season Selector */}
|
||||||
|
<div className="relative" ref={seasonRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSeasonOpen(!seasonOpen)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-700 transition-colors min-w-[140px] justify-between"
|
||||||
|
>
|
||||||
|
<span>{t.player.selectSeason} {selectedSeason}</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${seasonOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{seasonOpen && (
|
||||||
|
<div className="absolute top-full mt-2 bg-gray-800 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto w-full">
|
||||||
|
{seasonList.map((season) => (
|
||||||
|
<button
|
||||||
|
key={season}
|
||||||
|
onClick={() => {
|
||||||
|
onSeasonChange(season);
|
||||||
|
setSeasonOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-700 transition-colors ${
|
||||||
|
selectedSeason === season ? 'bg-gray-700 text-accent' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.player.selectSeason} {season}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Episode Selector */}
|
||||||
|
<div className="relative" ref={episodeRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setEpisodeOpen(!episodeOpen)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-700 transition-colors min-w-[140px] justify-between"
|
||||||
|
>
|
||||||
|
<span>{t.player.selectEpisode} {selectedEpisode}</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${episodeOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{episodeOpen && (
|
||||||
|
<div className="absolute top-full mt-2 bg-gray-800 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto w-full">
|
||||||
|
{episodeList.map((episode) => (
|
||||||
|
<button
|
||||||
|
key={episode}
|
||||||
|
onClick={() => {
|
||||||
|
onEpisodeChange(episode);
|
||||||
|
setEpisodeOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-700 transition-colors ${
|
||||||
|
selectedEpisode === episode ? 'bg-gray-700 text-accent' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.player.selectEpisode} {episode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { favoritesAPI } from '@/lib/favoritesApi';
|
|||||||
import { Heart } from 'lucide-react';
|
import { Heart } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
interface FavoriteButtonProps {
|
interface FavoriteButtonProps {
|
||||||
mediaId: string | number;
|
mediaId: string | number;
|
||||||
@@ -14,8 +15,10 @@ interface FavoriteButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className, showText = false }: FavoriteButtonProps) {
|
export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className, showText = false }: FavoriteButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const mediaIdString = mediaId.toString();
|
const mediaIdString = mediaId.toString();
|
||||||
|
|
||||||
@@ -23,39 +26,47 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
|
|||||||
const checkFavorite = async () => {
|
const checkFavorite = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const { data } = await favoritesAPI.checkFavorite(mediaIdString);
|
const response = await favoritesAPI.checkIsFavorite(mediaIdString, mediaType);
|
||||||
setIsFavorite(!!data.exists);
|
// Обрабатываем как обёрнутый, так и прямой ответ
|
||||||
} catch (error) {
|
const data = response.data?.data || response.data;
|
||||||
|
setIsFavorite(data?.isFavorite || false);
|
||||||
|
} catch (error: any) {
|
||||||
console.error('Error checking favorite status:', error);
|
console.error('Error checking favorite status:', error);
|
||||||
|
// Если 401, токен уже обновится автоматически через interceptor
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
return; // Подождём автоматического перелогина
|
||||||
|
}
|
||||||
|
// При других ошибках сбрасываем статус избранного
|
||||||
|
setIsFavorite(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkFavorite();
|
checkFavorite();
|
||||||
}, [token, mediaIdString]);
|
}, [token, mediaIdString, mediaType]);
|
||||||
|
|
||||||
const toggleFavorite = async () => {
|
const toggleFavorite = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('Для добавления в избранное необходимо авторизоваться');
|
toast.error(t.favorites.loginRequired);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
await favoritesAPI.removeFavorite(mediaIdString);
|
await favoritesAPI.removeFavorite(mediaIdString, mediaType);
|
||||||
toast.success('Удалено из избранного');
|
toast.success(t.favorites.removedFromFavorites);
|
||||||
setIsFavorite(false);
|
setIsFavorite(false);
|
||||||
} else {
|
} else {
|
||||||
await favoritesAPI.addFavorite({
|
await favoritesAPI.addFavorite(mediaIdString, mediaType);
|
||||||
mediaId: mediaIdString,
|
toast.success(t.favorites.addedToFavorites);
|
||||||
mediaType,
|
|
||||||
title,
|
|
||||||
posterPath: posterPath || '',
|
|
||||||
});
|
|
||||||
toast.success('Добавлено в избранное');
|
|
||||||
setIsFavorite(true);
|
setIsFavorite(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite:', error);
|
console.error('Error toggling favorite:', error);
|
||||||
toast.error('Произошла ошибка');
|
toast.error(t.common.error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,14 +77,15 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
|
|||||||
'gap-2 rounded-md px-4 py-3 text-base': showText,
|
'gap-2 rounded-md px-4 py-3 text-base': showText,
|
||||||
'bg-red-100 text-red-700 hover:bg-red-200 focus-visible:outline-red-600': isFavorite,
|
'bg-red-100 text-red-700 hover:bg-red-200 focus-visible:outline-red-600': isFavorite,
|
||||||
'bg-warm-200/80 text-warm-800 hover:bg-warm-300/90 backdrop-blur-sm': !isFavorite,
|
'bg-warm-200/80 text-warm-800 hover:bg-warm-300/90 backdrop-blur-sm': !isFavorite,
|
||||||
|
'opacity-50 cursor-not-allowed': loading,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={toggleFavorite} className={buttonClasses}>
|
<button type="button" onClick={toggleFavorite} disabled={loading} className={buttonClasses}>
|
||||||
<Heart size={20} className={cn({ 'fill-current': isFavorite })} />
|
<Heart size={20} className={cn({ 'fill-current': isFavorite })} />
|
||||||
{showText && <span>{isFavorite ? 'В избранном' : 'В избранное'}</span>}
|
{showText && <span>{isFavorite ? t.favorites.inFavorites : t.favorites.addToFavorites}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { Search, Sun, Moon, User, Menu, Settings } from "lucide-react";
|
import { Search, Sun, Moon, User, Menu, Settings } from "lucide-react";
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -37,6 +38,7 @@ const ThemeToggleButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => void }) {
|
export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [userName, setUserName] = useState<string | null>(
|
const [userName, setUserName] = useState<string | null>(
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('userName') : null
|
typeof window !== 'undefined' ? localStorage.getItem('userName') : null
|
||||||
);
|
);
|
||||||
@@ -74,7 +76,7 @@ export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => voi
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск фильмов и сериалов..."
|
placeholder={t.search.placeholder}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-full py-2 pl-10 pr-4 text-sm text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-full py-2 pl-10 pr-4 text-sm text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||||
@@ -95,7 +97,7 @@ export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => voi
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/login" className="text-sm font-medium p-2 rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors">
|
<Link href="/login" className="text-sm font-medium p-2 rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors">
|
||||||
Вход
|
{t.nav.login}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button onClick={onBurgerClick} className="md:hidden p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">
|
<button onClick={onBurgerClick} className="md:hidden p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">
|
||||||
@@ -107,9 +109,9 @@ export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => voi
|
|||||||
{/* Bottom bar */}
|
{/* Bottom bar */}
|
||||||
<div className="hidden md:flex items-center justify-center h-12 border-t border-gray-200 dark:border-gray-800">
|
<div className="hidden md:flex items-center justify-center h-12 border-t border-gray-200 dark:border-gray-800">
|
||||||
<nav className="flex items-center space-x-8">
|
<nav className="flex items-center space-x-8">
|
||||||
<NavLink href="/">Фильмы</NavLink>
|
<NavLink href="/">{t.nav.movies}</NavLink>
|
||||||
<NavLink href="/categories">Категории</NavLink>
|
<NavLink href="/categories">{t.nav.categories || 'Категории'}</NavLink>
|
||||||
<NavLink href="/favorites">Избранное</NavLink>
|
<NavLink href="/favorites">{t.nav.favorites}</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { X, Home, Clapperboard, Star } from 'lucide-react';
|
import { X, Home, Clapperboard, Star } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
const NavLink = ({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void; }) => (
|
const NavLink = ({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void; }) => (
|
||||||
<Link href={href} onClick={onClick} className="flex items-center gap-4 p-4 text-lg rounded-md text-gray-300 hover:bg-gray-800">
|
<Link href={href} onClick={onClick} className="flex items-center gap-4 p-4 text-lg rounded-md text-gray-300 hover:bg-gray-800">
|
||||||
@@ -10,6 +11,8 @@ const NavLink = ({ href, children, onClick }: { href: string; children: React.Re
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function MobileNav({ show, onClose }: { show: boolean; onClose: () => void; }) {
|
export default function MobileNav({ show, onClose }: { show: boolean; onClose: () => void; }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,15 +25,15 @@ export default function MobileNav({ show, onClose }: { show: boolean; onClose: (
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h2 className="text-xl font-semibold text-white">Меню</h2>
|
<h2 className="text-xl font-semibold text-white">{t.nav.menu || 'Меню'}</h2>
|
||||||
<button onClick={onClose} className="p-2 text-white">
|
<button onClick={onClose} className="p-2 text-white">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-col gap-2">
|
<nav className="flex flex-col gap-2">
|
||||||
<NavLink href="/" onClick={onClose}><Home size={20}/>Фильмы</NavLink>
|
<NavLink href="/" onClick={onClose}><Home size={20}/>{t.nav.movies}</NavLink>
|
||||||
<NavLink href="/categories" onClick={onClose}><Clapperboard size={20}/>Категории</NavLink>
|
<NavLink href="/categories" onClick={onClose}><Clapperboard size={20}/>{t.nav.categories || 'Категории'}</NavLink>
|
||||||
<NavLink href="/favorites" onClick={onClose}><Star size={20}/>Избранное</NavLink>
|
<NavLink href="/favorites" onClick={onClose}><Star size={20}/>{t.nav.favorites}</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Image from 'next/image';
|
|||||||
import { Movie, TVShow } from '@/types/movie';
|
import { Movie, TVShow } from '@/types/movie';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
import { useImageLoader } from '@/hooks/useImageLoader';
|
import { useImageLoader } from '@/hooks/useImageLoader';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
import { unifyMovieData } from '@/lib/dataUtils';
|
||||||
|
|
||||||
// Тип-гард для проверки, является ли объект сериалом
|
// Тип-гард для проверки, является ли объект сериалом
|
||||||
function isTVShow(media: Movie | TVShow): media is TVShow {
|
function isTVShow(media: Movie | TVShow): media is TVShow {
|
||||||
@@ -24,15 +26,17 @@ const getRatingColor = (rating: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MovieCard({ movie, priority = false }: MovieCardProps) {
|
export default function MovieCard({ movie, priority = false }: MovieCardProps) {
|
||||||
// Определяем, это фильм или сериал с помощью тип-гарда
|
const { t, locale } = useTranslation();
|
||||||
const isTV = isTVShow(movie);
|
const isTV = isTVShow(movie);
|
||||||
|
const unified = unifyMovieData(movie);
|
||||||
|
|
||||||
// Используем правильный заголовок и дату в зависимости от типа
|
const title = isTV ? movie.name || t.common.untitled : movie.title || t.common.untitled;
|
||||||
const title = isTV ? movie.name || 'Без названия' : movie.title || 'Без названия';
|
|
||||||
const date = isTV ? movie.first_air_date : movie.release_date;
|
const date = isTV ? movie.first_air_date : movie.release_date;
|
||||||
|
|
||||||
// Выбираем правильный URL
|
const idType = locale === 'ru' ? 'kp' : 'tmdb';
|
||||||
const url = isTV ? `/tv/${movie.id}` : `/movie/${movie.id}`;
|
const id = movie.id;
|
||||||
|
const sourceId = `${idType}_${id}`;
|
||||||
|
const url = isTV ? `/tv/${sourceId}` : `/movie/${sourceId}`;
|
||||||
|
|
||||||
// Загружаем изображение с оптимизированным размером для конкретного устройства
|
// Загружаем изображение с оптимизированным размером для конкретного устройства
|
||||||
// Используем меньший размер изображения для мобильных устройств
|
// Используем меньший размер изображения для мобильных устройств
|
||||||
|
|||||||
@@ -1,49 +1,91 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings, getAvailablePlayers } from '@/hooks/useSettings';
|
||||||
import { moviesAPI } from '@/lib/neoApi';
|
import { moviesAPI, tvShowsAPI } from '@/lib/neoApi';
|
||||||
import { AlertTriangle, Info } from 'lucide-react';
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
interface MoviePlayerProps {
|
interface MoviePlayerProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
poster: string;
|
poster: string;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
|
kinopoiskId?: string;
|
||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
onPlayerChange?: (player: string) => void;
|
||||||
|
selectedPlayer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen = false }: MoviePlayerProps) {
|
export default function MoviePlayer({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
poster,
|
||||||
|
imdbId,
|
||||||
|
kinopoiskId,
|
||||||
|
isFullscreen = false,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
onPlayerChange,
|
||||||
|
selectedPlayer
|
||||||
|
}: MoviePlayerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { settings, isInitialized } = useSettings();
|
const { settings, isInitialized } = useSettings();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||||
const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null);
|
const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null);
|
||||||
|
const [resolvedKinopoiskId, setResolvedKinopoiskId] = useState<number | null>(kinopoiskId ? parseInt(kinopoiskId) : null);
|
||||||
|
const [currentPlayer, setCurrentPlayer] = useState<string>(selectedPlayer || settings.defaultPlayer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchImdbId = async () => {
|
const fetchExternalIds = async () => {
|
||||||
if (imdbId) {
|
if (imdbId && kinopoiskId) {
|
||||||
setResolvedImdb(imdbId);
|
setResolvedImdb(imdbId);
|
||||||
|
setResolvedKinopoiskId(parseInt(kinopoiskId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (imdbId || kinopoiskId) {
|
||||||
|
if (imdbId) setResolvedImdb(imdbId);
|
||||||
|
if (kinopoiskId) setResolvedKinopoiskId(parseInt(kinopoiskId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const { data } = await moviesAPI.getMovie(id);
|
const externalIdsAPI = season !== undefined ? tvShowsAPI : moviesAPI;
|
||||||
if (!data?.imdb_id) throw new Error('IMDb ID не найден');
|
const externalIds = await externalIdsAPI.getExternalIds(id);
|
||||||
setResolvedImdb(data.imdb_id);
|
if (externalIds?.imdb_id) {
|
||||||
|
setResolvedImdb(externalIds.imdb_id);
|
||||||
|
}
|
||||||
|
if (externalIds?.kinopoisk_id) {
|
||||||
|
setResolvedKinopoiskId(externalIds.kinopoisk_id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching IMDb ID:', err);
|
console.error('Error fetching external IDs:', err);
|
||||||
setError('Не удалось получить информацию для плеера.');
|
setError(t.player.errorInfo);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchImdbId();
|
fetchExternalIds();
|
||||||
}, [id, imdbId]);
|
}, [id, imdbId, kinopoiskId, season]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized || !resolvedImdb) return;
|
if (selectedPlayer) {
|
||||||
|
setCurrentPlayer(selectedPlayer);
|
||||||
|
}
|
||||||
|
}, [selectedPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
|
||||||
|
// Проверяем, есть ли хотя бы один из ID (IMDB или KP)
|
||||||
|
if (!resolvedImdb && !resolvedKinopoiskId) return;
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
if (!API_BASE_URL) {
|
if (!API_BASE_URL) {
|
||||||
@@ -51,31 +93,111 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayerEndpoint = (player: string) => {
|
const getPlayerUrl = (player: string, imdbId: string | null, movieId: string, kpId: number | null) => {
|
||||||
|
const mediaType = (season !== undefined && episode !== undefined) ? 'tv' : 'movie';
|
||||||
|
const queryParams = (season !== undefined && episode !== undefined)
|
||||||
|
? `?season=${season}&episode=${episode}`
|
||||||
|
: '';
|
||||||
|
|
||||||
switch (player) {
|
switch (player) {
|
||||||
case 'alloha':
|
case 'alloha':
|
||||||
return '/api/v1/players/alloha';
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/kp/${kpId}${queryParams}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/imdb/${imdbId}${queryParams}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'lumex':
|
case 'lumex':
|
||||||
return '/api/v1/players/lumex';
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/lumex/kp/${kpId}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/lumex/imdb/${imdbId}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'vibix':
|
case 'vibix':
|
||||||
return '/api/v1/players/vibix';
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/vibix/kp/${kpId}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/vibix/imdb/${imdbId}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vidsrc':
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/vidsrc/${mediaType}/${imdbId}${queryParams}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vidlink':
|
||||||
|
if (mediaType === 'movie' && imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/vidlink/movie/${imdbId}`;
|
||||||
|
} else if (mediaType === 'tv') {
|
||||||
|
// Vidlink TV использует TMDB ID, но у нас его может не быть
|
||||||
|
// Попробуем использовать movieId как fallback
|
||||||
|
return `${API_BASE_URL}/api/v1/players/vidlink/tv/${movieId}${queryParams}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hdvb':
|
||||||
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/hdvb/kp/${kpId}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/hdvb/imdb/${imdbId}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return '/api/v1/players/alloha';
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/kp/${kpId}${queryParams}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/imdb/${imdbId}${queryParams}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если не удалось построить URL для выбранного плеера, пробуем fallback
|
||||||
|
if (kpId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/kp/${kpId}${queryParams}`;
|
||||||
|
}
|
||||||
|
if (imdbId) {
|
||||||
|
return `${API_BASE_URL}/api/v1/players/alloha/imdb/${imdbId}${queryParams}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playerEndpoint = getPlayerEndpoint(settings.defaultPlayer);
|
const newIframeSrc = getPlayerUrl(currentPlayer, resolvedImdb, id, resolvedKinopoiskId);
|
||||||
|
|
||||||
// Формируем URL, где imdbId является частью пути
|
// Детальное логирование для отладки
|
||||||
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
|
console.log('🎬 MoviePlayer URL Generation:', {
|
||||||
|
player: currentPlayer,
|
||||||
|
imdbId: resolvedImdb,
|
||||||
|
kinopoiskId: resolvedKinopoiskId,
|
||||||
|
movieId: id,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
generatedUrl: newIframeSrc
|
||||||
|
});
|
||||||
|
|
||||||
setIframeSrc(newIframeSrc);
|
if (newIframeSrc) {
|
||||||
setLoading(false);
|
setIframeSrc(newIframeSrc);
|
||||||
}, [resolvedImdb, isInitialized, settings.defaultPlayer]);
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setError('Не удалось построить URL плеера для доступных ID');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [resolvedImdb, isInitialized, currentPlayer, id, season, episode]);
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!resolvedImdb) {
|
if (!resolvedImdb && !resolvedKinopoiskId) {
|
||||||
const event = new Event('fetchImdb');
|
const event = new Event('fetchImdb');
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
} else {
|
} else {
|
||||||
@@ -89,23 +211,53 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayerEndpoint = (player: string) => {
|
const getPlayerUrl = (player: string, imdbId: string | null, movieId: string, kpId: number | null) => {
|
||||||
|
const mediaType = (season !== undefined && episode !== undefined) ? 'tv' : 'movie';
|
||||||
|
const queryParams = (season !== undefined && episode !== undefined)
|
||||||
|
? `?season=${season}&episode=${episode}`
|
||||||
|
: '';
|
||||||
|
|
||||||
switch (player) {
|
switch (player) {
|
||||||
case 'alloha':
|
case 'alloha':
|
||||||
return '/api/v1/players/alloha';
|
if (kpId) return `${API_BASE_URL}/api/v1/players/alloha/kp/${kpId}${queryParams}`;
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/alloha/imdb/${imdbId}${queryParams}`;
|
||||||
|
break;
|
||||||
case 'lumex':
|
case 'lumex':
|
||||||
return '/api/v1/players/lumex';
|
if (kpId) return `${API_BASE_URL}/api/v1/players/lumex/kp/${kpId}`;
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/lumex/imdb/${imdbId}`;
|
||||||
|
break;
|
||||||
case 'vibix':
|
case 'vibix':
|
||||||
return '/api/v1/players/vibix';
|
if (kpId) return `${API_BASE_URL}/api/v1/players/vibix/kp/${kpId}`;
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/vibix/imdb/${imdbId}`;
|
||||||
|
break;
|
||||||
|
case 'vidsrc':
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/vidsrc/${mediaType}/${imdbId}${queryParams}`;
|
||||||
|
break;
|
||||||
|
case 'vidlink':
|
||||||
|
if (mediaType === 'movie' && imdbId) return `${API_BASE_URL}/api/v1/players/vidlink/movie/${imdbId}`;
|
||||||
|
if (mediaType === 'tv') return `${API_BASE_URL}/api/v1/players/vidlink/tv/${movieId}${queryParams}`;
|
||||||
|
break;
|
||||||
|
case 'hdvb':
|
||||||
|
if (kpId) return `${API_BASE_URL}/api/v1/players/hdvb/kp/${kpId}`;
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/hdvb/imdb/${imdbId}`;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return '/api/v1/players/alloha';
|
if (kpId) return `${API_BASE_URL}/api/v1/players/alloha/kp/${kpId}${queryParams}`;
|
||||||
|
if (imdbId) return `${API_BASE_URL}/api/v1/players/alloha/imdb/${imdbId}${queryParams}`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playerEndpoint = getPlayerEndpoint(settings.defaultPlayer);
|
const newIframeSrc = getPlayerUrl(settings.defaultPlayer, resolvedImdb, id, resolvedKinopoiskId);
|
||||||
const newIframeSrc = `${API_BASE_URL}${playerEndpoint}/${resolvedImdb}`;
|
if (newIframeSrc) {
|
||||||
setIframeSrc(newIframeSrc);
|
setIframeSrc(newIframeSrc);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setError('Не удалось построить URL плеера для доступных ID');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +294,7 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
) : (
|
) : (
|
||||||
loading && (
|
loading && (
|
||||||
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center text-warm-300">
|
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center text-warm-300">
|
||||||
Загрузка плеера...
|
{t.player.loading}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -150,7 +302,7 @@ export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen =
|
|||||||
{settings.defaultPlayer !== 'lumex' && !isFullscreen && (
|
{settings.defaultPlayer !== 'lumex' && !isFullscreen && (
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-100 p-3 text-sm text-blue-800">
|
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-100 p-3 text-sm text-blue-800">
|
||||||
<Info size={20} />
|
<Info size={20} />
|
||||||
<span>Для возможности скачивания фильма выберите плеер Lumex в настройках.</span>
|
<span>{t.player.downloadHint}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getImageUrl } from "@/lib/neoApi";
|
import { getImageUrl as getImageUrlOriginal } from "@/lib/neoApi";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import FavoriteButton from "./FavoriteButton";
|
import FavoriteButton from "./FavoriteButton";
|
||||||
|
import { unifyMovieData, formatRating, getImageUrl } from '@/lib/dataUtils';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export interface MovieLike {
|
export interface MovieLike {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,15 +16,22 @@ export interface MovieLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MovieTile({ movie }: { movie: MovieLike }) {
|
export default function MovieTile({ movie }: { movie: MovieLike }) {
|
||||||
const fullDate = movie.release_date ? formatDate(movie.release_date) : "";
|
const { locale } = useTranslation();
|
||||||
|
const unified = unifyMovieData(movie);
|
||||||
|
const fullDate = unified.releaseDate ? formatDate(unified.releaseDate) : "";
|
||||||
|
const posterUrl = getImageUrl(unified.posterPath, "w342");
|
||||||
|
const rating = formatRating(unified.voteAverage);
|
||||||
|
|
||||||
|
const idType = locale === 'ru' ? 'kp' : 'tmdb';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex-shrink-0">
|
<div className="w-full flex-shrink-0">
|
||||||
<div className="relative aspect-[2/3] overflow-hidden rounded-md bg-gray-200 dark:bg-gray-800 shadow-sm">
|
<div className="relative aspect-[2/3] overflow-hidden rounded-md bg-gray-200 dark:bg-gray-800 shadow-sm">
|
||||||
<Link href={`/movie/${movie.id}`}>
|
<Link href={`/movie/${idType}_${unified.id}`}>
|
||||||
{movie.poster_path ? (
|
{posterUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={getImageUrl(movie.poster_path, "w342")}
|
src={posterUrl}
|
||||||
alt={movie.title}
|
alt={unified.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform hover:scale-105"
|
className="object-cover transition-transform hover:scale-105"
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -35,18 +44,18 @@ export default function MovieTile({ movie }: { movie: MovieLike }) {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="absolute right-1 top-1 z-10">
|
<div className="absolute right-1 top-1 z-10">
|
||||||
<FavoriteButton
|
<FavoriteButton
|
||||||
mediaId={movie.id.toString()}
|
mediaId={unified.id.toString()}
|
||||||
mediaType="movie"
|
mediaType={unified.isSerial ? "tv" : "movie"}
|
||||||
title={movie.title}
|
title={unified.title}
|
||||||
posterPath={movie.poster_path}
|
posterPath={unified.posterPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/movie/${movie.id}`} className="mt-2 block text-sm font-medium leading-snug text-foreground hover:text-accent">
|
<Link href={`/movie/${idType}_${unified.id}`} className="mt-2 block text-sm font-medium leading-snug text-foreground hover:text-accent">
|
||||||
{movie.title}
|
{unified.title}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{fullDate} {movie.vote_average ? `· ${movie.vote_average.toFixed(1)}` : ""}
|
{fullDate} {rating !== '—' ? `· ${rating}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
40
src/components/PlayerSelector.tsx
Normal file
40
src/components/PlayerSelector.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getAvailablePlayers } from '@/hooks/useSettings';
|
||||||
|
|
||||||
|
interface PlayerSelectorProps {
|
||||||
|
language: 'ru' | 'en';
|
||||||
|
selectedPlayer: string;
|
||||||
|
onPlayerChange: (player: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerNames: Record<string, string> = {
|
||||||
|
alloha: 'Alloha',
|
||||||
|
lumex: 'Lumex',
|
||||||
|
vibix: 'Vibix',
|
||||||
|
vidsrc: 'Vidsrc',
|
||||||
|
vidlink: 'Vidlink',
|
||||||
|
hdvb: 'HDVB',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayerSelector({ language, selectedPlayer, onPlayerChange }: PlayerSelectorProps) {
|
||||||
|
const availablePlayers = getAvailablePlayers(language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{availablePlayers.map((playerId) => (
|
||||||
|
<button
|
||||||
|
key={playerId}
|
||||||
|
onClick={() => onPlayerChange(playerId)}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-all ${
|
||||||
|
selectedPlayer === playerId
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{playerNames[playerId]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,157 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings, getAvailablePlayers } from '@/hooks/useSettings';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
import { Globe, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export default function SettingsContent() {
|
export default function SettingsContent() {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const players = [
|
const allPlayers = {
|
||||||
{
|
alloha: {
|
||||||
id: 'alloha',
|
id: 'alloha',
|
||||||
name: 'Alloha',
|
name: 'Alloha',
|
||||||
description: 'Основной плеер с высоким качеством и быстрой загрузкой.',
|
description: 'Основной плеер с высоким качеством и быстрой загрузкой.',
|
||||||
|
language: 'ru',
|
||||||
},
|
},
|
||||||
{
|
lumex: {
|
||||||
id: 'lumex',
|
id: 'lumex',
|
||||||
name: 'Lumex',
|
name: 'Lumex',
|
||||||
description: 'Альтернативный плеер, может быть полезен при проблемах с основным.',
|
description: 'Альтернативный плеер, может быть полезен при проблемах с основным.',
|
||||||
|
language: 'ru',
|
||||||
},
|
},
|
||||||
{
|
vibix: {
|
||||||
id: 'vibix',
|
id: 'vibix',
|
||||||
name: 'Vibix',
|
name: 'Vibix',
|
||||||
description: 'Современный плеер с адаптивным качеством и стабильной работой.',
|
description: 'Современный плеер с адаптивным качеством и стабильной работой.',
|
||||||
|
language: 'ru',
|
||||||
},
|
},
|
||||||
];
|
hdvb: {
|
||||||
|
id: 'hdvb',
|
||||||
|
name: 'HDVB',
|
||||||
|
description: 'Плеер с поддержкой Kinopoisk ID и русской озвучкой.',
|
||||||
|
language: 'ru',
|
||||||
|
},
|
||||||
|
vidsrc: {
|
||||||
|
id: 'vidsrc',
|
||||||
|
name: 'Vidsrc',
|
||||||
|
description: 'Популярный плеер с большой базой контента и множеством источников.',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
vidlink: {
|
||||||
|
id: 'vidlink',
|
||||||
|
name: 'Vidlink',
|
||||||
|
description: 'Стабильный плеер с надежной работой и хорошим качеством.',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const availablePlayerIds = getAvailablePlayers(settings.language);
|
||||||
|
const players = availablePlayerIds
|
||||||
|
.map(id => allPlayers[id as keyof typeof allPlayers])
|
||||||
|
.filter(player => player !== undefined);
|
||||||
|
|
||||||
const handlePlayerSelect = (playerId: string) => {
|
const handlePlayerSelect = (playerId: string) => {
|
||||||
updateSettings({ defaultPlayer: playerId as 'alloha' | 'lumex' | 'vibix' });
|
updateSettings({ defaultPlayer: playerId as any });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInterfaceLanguageChange = (interfaceLanguage: 'ru' | 'en') => {
|
||||||
|
updateSettings({ interfaceLanguage });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayerLanguageChange = (language: 'ru' | 'en') => {
|
||||||
|
updateSettings({ language });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl space-y-6">
|
||||||
|
{/* Interface Language Selection */}
|
||||||
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8">
|
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8">
|
||||||
<h2 className="text-xl font-bold text-foreground mb-4">Настройки плеера</h2>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<p className="text-muted-foreground mb-6">Выберите плеер, который будет использоваться по умолчанию для просмотра.</p>
|
<Globe className="w-6 h-6 text-accent" />
|
||||||
|
<h2 className="text-xl font-bold text-foreground">{t.settings.language}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mb-6">{t.settings.languageDescription}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => handleInterfaceLanguageChange('ru')}
|
||||||
|
className={`rounded-lg p-4 cursor-pointer border-2 transition-all text-center ${
|
||||||
|
settings.interfaceLanguage === 'ru'
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.settings.russian}</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => handleInterfaceLanguageChange('en')}
|
||||||
|
className={`rounded-lg p-4 cursor-pointer border-2 transition-all text-center ${
|
||||||
|
settings.interfaceLanguage === 'en'
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.settings.english}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Language Selection */}
|
||||||
|
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8">
|
||||||
|
<h2 className="text-xl font-bold text-foreground mb-4">{t.settings.playerLanguage}</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">{t.settings.playerLanguageDescription}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => handlePlayerLanguageChange('ru')}
|
||||||
|
className={`rounded-lg p-4 cursor-pointer border-2 transition-all text-center ${
|
||||||
|
settings.language === 'ru'
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.settings.russian}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t.settings.russianPlayers}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => handlePlayerLanguageChange('en')}
|
||||||
|
className={`rounded-lg p-4 cursor-pointer border-2 transition-all text-center ${
|
||||||
|
settings.language === 'en'
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.settings.english}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t.settings.englishPlayers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AdBlocker Warning for English Players */}
|
||||||
|
{settings.language === 'en' && (
|
||||||
|
<div className="mt-4 bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-600 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-yellow-900 dark:text-yellow-200 mb-2">
|
||||||
|
{t.settings.adBlockerWarning}
|
||||||
|
</h3>
|
||||||
|
<p className="text-yellow-800 dark:text-yellow-300 text-sm mb-2">
|
||||||
|
{t.settings.adBlockerText}
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-800 dark:text-yellow-300 text-sm font-semibold">
|
||||||
|
{t.settings.adBlockerRecommendation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Selection */}
|
||||||
|
<div className="bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-6 sm:p-8">
|
||||||
|
<h2 className="text-xl font-bold text-foreground mb-4">{t.settings.playerSettings}</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">{t.settings.playerSettingsDescription}</p>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<div
|
<div
|
||||||
@@ -44,7 +163,7 @@ export default function SettingsContent() {
|
|||||||
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
: 'border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:border-accent/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-foreground">{player.name}</h3>
|
<h3 className="font-semibold text-foreground mb-2">{player.name}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{player.description}</p>
|
<p className="text-sm text-muted-foreground">{player.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react';
|
import { Loader2, AlertTriangle, Copy, Check, Download, ExternalLink } from 'lucide-react';
|
||||||
import { torrentsAPI, type TorrentResult } from '@/lib/neoApi';
|
import { torrentsAPI, type TorrentResult } from '@/lib/neoApi';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
interface TorrentSelectorProps {
|
interface TorrentSelectorProps {
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
@@ -21,6 +22,7 @@ interface ParsedTorrent extends TorrentResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TorrentSelector({ imdbId, type, title, originalTitle, year }: TorrentSelectorProps) {
|
export default function TorrentSelector({ imdbId, type, title, originalTitle, year }: TorrentSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [torrents, setTorrents] = useState<ParsedTorrent[] | null>(null);
|
const [torrents, setTorrents] = useState<ParsedTorrent[] | null>(null);
|
||||||
const [availableSeasons, setAvailableSeasons] = useState<number[]>([]);
|
const [availableSeasons, setAvailableSeasons] = useState<number[]>([]);
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||||
@@ -98,19 +100,36 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
console.log('Fetching torrents for IMDB ID:', imdbId, 'Type:', type);
|
||||||
const response = await torrentsAPI.searchTorrents(imdbId!, type);
|
const response = await torrentsAPI.searchTorrents(imdbId!, type);
|
||||||
if (response.data.total === 0) {
|
console.log('Torrents API response:', response);
|
||||||
setError('Торренты не найдены.');
|
console.log('Torrents data:', response.data);
|
||||||
|
|
||||||
|
const total = response.data?.total || 0;
|
||||||
|
const results = response.data?.results || [];
|
||||||
|
|
||||||
|
console.log('Total torrents:', total, 'Results count:', results.length);
|
||||||
|
|
||||||
|
if (total === 0 || results.length === 0) {
|
||||||
|
console.warn('No torrents found');
|
||||||
|
setError(t.torrents.notFound);
|
||||||
} else {
|
} else {
|
||||||
const parsedTorrents: ParsedTorrent[] = response.data.results.map(torrent => ({
|
const parsedTorrents: ParsedTorrent[] = results.map((torrent: TorrentResult) => ({
|
||||||
...torrent,
|
...torrent,
|
||||||
quality: parseQuality(torrent.title || ''),
|
quality: parseQuality(torrent.title || ''),
|
||||||
season: type === 'tv' ? parseSeason(torrent.title || '') : undefined,
|
season: type === 'tv' ? parseSeason(torrent.title || '') : undefined,
|
||||||
sizeFormatted: formatSize(torrent.size || 0)
|
sizeFormatted: formatSize(torrent.size || 0)
|
||||||
}));
|
}));
|
||||||
|
console.log('Parsed torrents:', parsedTorrents);
|
||||||
setTorrents(parsedTorrents);
|
setTorrents(parsedTorrents);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
|
console.error('Error loading torrents:', err);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: err?.message,
|
||||||
|
response: err?.response?.data,
|
||||||
|
status: err?.response?.status
|
||||||
|
});
|
||||||
setError('Не удалось загрузить список торрентов.');
|
setError('Не удалось загрузить список торрентов.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -221,17 +240,10 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1 border-gray-300 text-gray-700 dark:border-zinc-600 dark:text-zinc-300"
|
className="flex-1 border-gray-300 text-gray-700 dark:border-zinc-600 dark:text-zinc-300"
|
||||||
>
|
>
|
||||||
{copiedMagnet === torrent.magnet ? (
|
<>
|
||||||
<>
|
{copiedMagnet === torrent.magnet ? <Check className="h-4 w-4 mr-2 text-green-500" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||||
<Check className="h-4 w-4 mr-2 text-green-500" />
|
{copiedMagnet === torrent.magnet ? t.torrents.copied : t.torrents.copy}
|
||||||
Скопировано
|
</>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
Копировать
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -240,7 +252,7 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white dark:bg-blue-700 dark:hover:bg-blue-800"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white dark:bg-blue-700 dark:hover:bg-blue-800"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
Скачать
|
{t.torrents.download}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +263,7 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-center p-4">
|
<div className="mt-4 flex items-center justify-center p-4">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-gray-500 dark:text-gray-400" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-gray-500 dark:text-gray-400" />
|
||||||
<span className="text-gray-700 dark:text-gray-300">Загрузка торрентов...</span>
|
<span className="text-gray-700 dark:text-gray-300">{t.torrents.loading}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,7 +287,7 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="w-full sm:w-auto" size="lg">
|
<Button className="w-full sm:w-auto" size="lg">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Скачать ({torrents.length} {torrents.length === 1 ? 'раздача' : torrents.length < 5 ? 'раздачи' : 'раздач'})
|
{t.torrents.download} ({torrents.length} {torrents.length === 1 ? t.torrents.releases.one : torrents.length < 5 ? t.torrents.releases.few : t.torrents.releases.many})
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
@@ -334,7 +346,7 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
onClick={() => setSelectedSeason(null)}
|
onClick={() => setSelectedSeason(null)}
|
||||||
className={selectedSeason === null ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
className={selectedSeason === null ? 'bg-zinc-800 text-white dark:bg-white dark:text-black' : 'text-gray-700 border-gray-300 dark:text-zinc-300 dark:border-zinc-600'}
|
||||||
>
|
>
|
||||||
Все сезоны
|
{t.torrents.allSeasons}
|
||||||
</Button>
|
</Button>
|
||||||
{availableSeasons.map(season => {
|
{availableSeasons.map(season => {
|
||||||
const count = torrents?.filter(t => t.season === season && (selectedQuality === 'all' || t.quality === selectedQuality)).length || 0;
|
const count = torrents?.filter(t => t.season === season && (selectedQuality === 'all' || t.quality === selectedQuality)).length || 0;
|
||||||
@@ -358,7 +370,7 @@ export default function TorrentSelector({ imdbId, type, title, originalTitle, ye
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredTorrents.length === 0 ? (
|
{filteredTorrents.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
Нет раздач, соответствующих выбранным фильтрам
|
{t.torrents.noMatches}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredTorrents.map((torrent, index) => (
|
filteredTorrents.map((torrent, index) => (
|
||||||
|
|||||||
81
src/contexts/TranslationContext.tsx
Normal file
81
src/contexts/TranslationContext.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { ru, en, type Translation } from '@/locales';
|
||||||
|
|
||||||
|
interface TranslationContextType {
|
||||||
|
t: Translation;
|
||||||
|
locale: 'ru' | 'en';
|
||||||
|
setLocale: (locale: 'ru' | 'en') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranslationContext = createContext<TranslationContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
ru,
|
||||||
|
en,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TranslationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<'ru' | 'en'>('ru');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Читаем язык из localStorage при загрузке
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const savedSettings = localStorage.getItem('settings');
|
||||||
|
if (savedSettings) {
|
||||||
|
const settings = JSON.parse(savedSettings);
|
||||||
|
setLocaleState(settings.interfaceLanguage || 'ru');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading translation settings:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Слушаем изменения настроек
|
||||||
|
const handleSettingsChange = (e: CustomEvent) => {
|
||||||
|
if (e.detail?.interfaceLanguage) {
|
||||||
|
setLocaleState(e.detail.interfaceLanguage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('settings-changed' as any, handleSettingsChange);
|
||||||
|
return () => window.removeEventListener('settings-changed' as any, handleSettingsChange);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = (newLocale: 'ru' | 'en') => {
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
|
||||||
|
// Обновляем localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const savedSettings = localStorage.getItem('settings');
|
||||||
|
const settings = savedSettings ? JSON.parse(savedSettings) : {};
|
||||||
|
settings.interfaceLanguage = newLocale;
|
||||||
|
localStorage.setItem('settings', JSON.stringify(settings));
|
||||||
|
|
||||||
|
// Диспатчим событие для синхронизации
|
||||||
|
window.dispatchEvent(new CustomEvent('settings-changed', { detail: settings }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving language:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = translations[locale] || translations.ru;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TranslationContext.Provider value={{ t, locale, setLocale }}>
|
||||||
|
{children}
|
||||||
|
</TranslationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranslation() {
|
||||||
|
const context = useContext(TranslationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTranslation must be used within TranslationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ export function useAuth() {
|
|||||||
const data = response.data.data || response.data;
|
const data = response.data.data || response.data;
|
||||||
if (data?.token) {
|
if (data?.token) {
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
|
|
||||||
|
// Сохраняем refresh токен, если он есть
|
||||||
|
if (data.refreshToken) {
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
let name: string | undefined = undefined;
|
let name: string | undefined = undefined;
|
||||||
let emailVal: string | undefined = undefined;
|
let emailVal: string | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +87,7 @@ export function useAuth() {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
delete neoApi.defaults.headers.common['Authorization'];
|
delete neoApi.defaults.headers.common['Authorization'];
|
||||||
localStorage.removeItem('userName');
|
localStorage.removeItem('userName');
|
||||||
localStorage.removeItem('userEmail');
|
localStorage.removeItem('userEmail');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { searchAPI } from '@/lib/neoApi';
|
|||||||
import type { Movie } from '@/lib/neoApi';
|
import type { Movie } from '@/lib/neoApi';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslation } from '@/contexts/TranslationContext';
|
||||||
|
|
||||||
export function useSearch() {
|
export function useSearch() {
|
||||||
const [results, setResults] = useState<Movie[]>([]);
|
const [results, setResults] = useState<Movie[]>([]);
|
||||||
@@ -16,16 +17,15 @@ export function useSearch() {
|
|||||||
const [searchFailed, setSearchFailed] = useState(false);
|
const [searchFailed, setSearchFailed] = useState(false);
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
|
||||||
const filterMovies = (movies: Movie[]) => {
|
const filterMovies = (movies: Movie[]) => {
|
||||||
return movies.filter(movie => {
|
return movies.filter(movie => {
|
||||||
if (movie.vote_average === 0) return false;
|
// Убираем фильтр по русским буквам, так как API KP может возвращать фильмы с оригинальными названиями
|
||||||
const hasRussianLetters = /[а-яА-ЯёЁ]/.test(movie.title);
|
if (!movie.title && !movie.name) return false;
|
||||||
if (!hasRussianLetters) return false;
|
const title = movie.title || movie.name || '';
|
||||||
if (/^\d+$/.test(movie.title)) return false;
|
if (/^\d+$/.test(title)) return false; // Исключаем чисто числовые названия
|
||||||
const releaseDate = new Date(movie.release_date);
|
// Убираем фильтр по дате релиза, так как это может быть неточно
|
||||||
const now = new Date();
|
|
||||||
if (releaseDate > now) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -48,15 +48,33 @@ export function useSearch() {
|
|||||||
setCurrentQuery(query);
|
setCurrentQuery(query);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
|
||||||
const response = await searchAPI.multiSearch(query, 1);
|
// Выбор источника: ru -> kp, иначе tmdb
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
const source: 'kp' | 'tmdb' = (locale || 'ru') === 'ru' ? 'kp' : 'tmdb';
|
||||||
|
const response = await searchAPI.multiSearch(query, source, 1);
|
||||||
|
|
||||||
|
// Адаптируем новый унифицированный формат к старому
|
||||||
|
const searchResults = response?.data || [];
|
||||||
|
const adaptedResults = searchResults.map((item: any) => ({
|
||||||
|
id: parseInt(item.id, 10),
|
||||||
|
title: item.title,
|
||||||
|
name: item.title,
|
||||||
|
media_type: item.type === 'tv' ? 'tv' : 'movie',
|
||||||
|
release_date: item.releaseDate,
|
||||||
|
first_air_date: item.releaseDate,
|
||||||
|
poster_path: item.posterUrl?.startsWith('http') ? item.posterUrl : item.posterUrl,
|
||||||
|
vote_average: item.rating || 0,
|
||||||
|
overview: item.description || '',
|
||||||
|
genre_ids: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredMovies = filterMovies(adaptedResults);
|
||||||
|
|
||||||
if (filteredMovies.length === 0) {
|
if (filteredMovies.length === 0) {
|
||||||
setSearchFailed(true);
|
setSearchFailed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setResults(filteredMovies);
|
setResults(filteredMovies);
|
||||||
setHasMore(response.data.total_pages > 1);
|
setHasMore(response?.pagination?.totalPages > 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при поиске:', err);
|
console.error('Ошибка при поиске:', err);
|
||||||
setError('Произошла ошибка при поиске');
|
setError('Произошла ошибка при поиске');
|
||||||
@@ -74,12 +92,29 @@ export function useSearch() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const nextPage = currentPage + 1;
|
const nextPage = currentPage + 1;
|
||||||
|
|
||||||
const response = await searchAPI.multiSearch(currentQuery, nextPage);
|
// Выбор источника: ru -> kp, иначе tmdb
|
||||||
const filteredMovies = filterMovies(response.data.results);
|
const source: 'kp' | 'tmdb' = (locale || 'ru') === 'ru' ? 'kp' : 'tmdb';
|
||||||
|
const response = await searchAPI.multiSearch(currentQuery, source, nextPage);
|
||||||
|
|
||||||
|
// Адаптируем новый унифицированный формат к старому
|
||||||
|
const searchResults = response?.data || [];
|
||||||
|
const adaptedResults = searchResults.map((item: any) => ({
|
||||||
|
id: parseInt(item.id, 10),
|
||||||
|
title: item.title,
|
||||||
|
name: item.title,
|
||||||
|
media_type: item.type === 'tv' ? 'tv' : 'movie',
|
||||||
|
release_date: item.releaseDate,
|
||||||
|
first_air_date: item.releaseDate,
|
||||||
|
poster_path: item.posterUrl?.startsWith('http') ? item.posterUrl : item.posterUrl,
|
||||||
|
vote_average: item.rating || 0,
|
||||||
|
overview: item.description || '',
|
||||||
|
genre_ids: []
|
||||||
|
}));
|
||||||
|
const filteredMovies = filterMovies(adaptedResults);
|
||||||
|
|
||||||
setResults(prev => [...prev, ...filteredMovies]);
|
setResults(prev => [...prev, ...filteredMovies]);
|
||||||
setCurrentPage(nextPage);
|
setCurrentPage(nextPage);
|
||||||
setHasMore(nextPage < response.data.total_pages && nextPage < 5); // Ограничиваем до 5 страниц
|
setHasMore(nextPage < (response?.pagination?.totalPages || 0) && nextPage < 5); // Ограничиваем до 5 страниц
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при загрузке дополнительных результатов:', err);
|
console.error('Ошибка при загрузке дополнительных результатов:', err);
|
||||||
setError('Произошла ошибка при загрузке дополнительных результатов');
|
setError('Произошла ошибка при загрузке дополнительных результатов');
|
||||||
|
|||||||
@@ -5,17 +5,44 @@ import { useState, useEffect } from 'react';
|
|||||||
interface Settings {
|
interface Settings {
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
language: 'ru' | 'en';
|
language: 'ru' | 'en';
|
||||||
|
interfaceLanguage: 'ru' | 'en';
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
defaultPlayer: 'alloha' | 'lumex' | 'vibix';
|
defaultPlayer: 'alloha' | 'lumex' | 'vibix' | 'vidsrc' | 'vidlink' | 'hdvb';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Определяем язык браузера
|
||||||
|
const detectBrowserLanguage = (): 'ru' | 'en' => {
|
||||||
|
if (typeof window === 'undefined') return 'ru';
|
||||||
|
|
||||||
|
const browserLang = navigator.language || (navigator as any).userLanguage;
|
||||||
|
|
||||||
|
// Если язык начинается с 'ru', используем русский
|
||||||
|
if (browserLang.startsWith('ru')) return 'ru';
|
||||||
|
|
||||||
|
// Для всех остальных - английский
|
||||||
|
return 'en';
|
||||||
|
};
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
language: 'ru',
|
language: 'ru',
|
||||||
|
interfaceLanguage: detectBrowserLanguage(),
|
||||||
notifications: true,
|
notifications: true,
|
||||||
defaultPlayer: 'alloha',
|
defaultPlayer: 'alloha',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Определяем плееры для русского и английского языка
|
||||||
|
export const getAvailablePlayers = (language: 'ru' | 'en') => {
|
||||||
|
const russianPlayers = ['alloha', 'lumex', 'vibix', 'hdvb'];
|
||||||
|
const englishPlayers = ['vidsrc', 'vidlink'];
|
||||||
|
|
||||||
|
return language === 'ru' ? russianPlayers : englishPlayers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultPlayerForLanguage = (language: 'ru' | 'en') => {
|
||||||
|
return language === 'ru' ? 'alloha' : 'vidsrc';
|
||||||
|
};
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
@@ -39,6 +66,10 @@ export function useSettings() {
|
|||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('settings', JSON.stringify(settings));
|
localStorage.setItem('settings', JSON.stringify(settings));
|
||||||
|
// Диспатчим событие для моментального обновления переводов
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('settings-changed', { detail: settings }));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving settings:', error);
|
console.error('Error saving settings:', error);
|
||||||
}
|
}
|
||||||
@@ -46,7 +77,19 @@ export function useSettings() {
|
|||||||
}, [settings, isInitialized]);
|
}, [settings, isInitialized]);
|
||||||
|
|
||||||
const updateSettings = (newSettings: Partial<Settings>) => {
|
const updateSettings = (newSettings: Partial<Settings>) => {
|
||||||
setSettings(prev => ({ ...prev, ...newSettings }));
|
setSettings(prev => {
|
||||||
|
const updated = { ...prev, ...newSettings };
|
||||||
|
|
||||||
|
// Если изменился язык, обновляем плеер на подходящий для этого языка
|
||||||
|
if (newSettings.language && newSettings.language !== prev.language) {
|
||||||
|
const availablePlayers = getAvailablePlayers(newSettings.language);
|
||||||
|
if (!availablePlayers.includes(updated.defaultPlayer)) {
|
||||||
|
updated.defaultPlayer = getDefaultPlayerForLanguage(newSettings.language) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetSettings = () => {
|
const resetSettings = () => {
|
||||||
|
|||||||
147
src/lib/dataUtils.ts
Normal file
147
src/lib/dataUtils.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
getMovieTitle,
|
||||||
|
getMovieOriginalTitle,
|
||||||
|
getMoviePoster,
|
||||||
|
getMovieBackdrop,
|
||||||
|
getMovieRating,
|
||||||
|
getMovieYear,
|
||||||
|
getMovieOverview,
|
||||||
|
isKPData,
|
||||||
|
isTMDBData
|
||||||
|
} from '@/types/kinopoisk';
|
||||||
|
|
||||||
|
export interface UnifiedMovie {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
originalTitle: string;
|
||||||
|
overview: string;
|
||||||
|
posterPath: string;
|
||||||
|
backdropPath: string;
|
||||||
|
releaseDate: string;
|
||||||
|
year: string;
|
||||||
|
voteAverage: number;
|
||||||
|
voteCount: number;
|
||||||
|
genres: Array<{ id: number; name: string }>;
|
||||||
|
runtime?: number;
|
||||||
|
imdbId?: string;
|
||||||
|
kinopoiskId?: number;
|
||||||
|
mediaType?: string;
|
||||||
|
isSerial?: boolean;
|
||||||
|
countries?: Array<{ name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unifyMovieData(movie: any): UnifiedMovie {
|
||||||
|
const isKP = isKPData(movie);
|
||||||
|
const isTMDB = isTMDBData(movie);
|
||||||
|
|
||||||
|
const kpId = movie.kinopoiskId || movie.filmId || movie.kinopoisk_id || (isKP ? movie.id : undefined);
|
||||||
|
const tmdbId = isTMDB ? movie.id : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: kpId || tmdbId || movie.id || 0,
|
||||||
|
title: getMovieTitle(movie),
|
||||||
|
originalTitle: getMovieOriginalTitle(movie),
|
||||||
|
overview: getMovieOverview(movie),
|
||||||
|
posterPath: getMoviePoster(movie),
|
||||||
|
backdropPath: getMovieBackdrop(movie),
|
||||||
|
releaseDate: movie.release_date || movie.first_air_date || (movie.year ? `${movie.year}-01-01` : ''),
|
||||||
|
year: getMovieYear(movie),
|
||||||
|
voteAverage: getMovieRating(movie),
|
||||||
|
voteCount: movie.ratingKinopoiskVoteCount || movie.ratingImdbVoteCount || movie.ratingVoteCount || movie.vote_count || 0,
|
||||||
|
genres: unifyGenres(movie),
|
||||||
|
runtime: movie.filmLength || movie.runtime || undefined,
|
||||||
|
imdbId: movie.imdbId || movie.imdb_id || undefined,
|
||||||
|
kinopoiskId: kpId,
|
||||||
|
mediaType: movie.media_type || movie.type || undefined,
|
||||||
|
isSerial: movie.serial || movie.media_type === 'tv' || movie.type === 'TV_SERIES' || movie.type === 'MINI_SERIES' || undefined,
|
||||||
|
countries: unifyCountries(movie)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unifyGenres(movie: any): Array<{ id: number; name: string }> {
|
||||||
|
if (movie.genres && Array.isArray(movie.genres)) {
|
||||||
|
if (movie.genres[0]?.genre) {
|
||||||
|
return movie.genres.map((g: any, index: number) => ({
|
||||||
|
id: index,
|
||||||
|
name: g.genre
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (movie.genres[0]?.name) {
|
||||||
|
return movie.genres;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (movie.genre_ids && Array.isArray(movie.genre_ids)) {
|
||||||
|
return movie.genre_ids.map((id: number) => ({ id, name: '' }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function unifyCountries(movie: any): Array<{ name: string }> {
|
||||||
|
if (movie.countries && Array.isArray(movie.countries)) {
|
||||||
|
if (movie.countries[0]?.country) {
|
||||||
|
return movie.countries.map((c: any) => ({ name: c.country }));
|
||||||
|
}
|
||||||
|
if (movie.countries[0]?.name) {
|
||||||
|
return movie.countries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (movie.production_countries && Array.isArray(movie.production_countries)) {
|
||||||
|
return movie.production_countries;
|
||||||
|
}
|
||||||
|
if (movie.origin_country && Array.isArray(movie.origin_country)) {
|
||||||
|
return movie.origin_country.map((code: string) => ({ name: code }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRating(rating: number | null | undefined): string {
|
||||||
|
if (!rating) return '—';
|
||||||
|
return rating.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRuntime(minutes: number | null | undefined): string {
|
||||||
|
if (!minutes) return '';
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}ч ${mins}м`;
|
||||||
|
}
|
||||||
|
return `${mins}м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageUrl(path: string | null | undefined, size: string = 'original'): string {
|
||||||
|
if (!path) return '/images/placeholder.jpg';
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.neomovies.ru';
|
||||||
|
// Всегда проксируем через наш API (включая абсолютные URL)
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
const encoded = encodeURIComponent(path);
|
||||||
|
return `${API_URL}/api/v1/images/${size}/${encoded}`;
|
||||||
|
}
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYear(year: string | number | null | undefined): string {
|
||||||
|
if (!year) return '';
|
||||||
|
return year.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenresString(genres: Array<{ id: number; name: string }>): string {
|
||||||
|
if (!genres || genres.length === 0) return '';
|
||||||
|
return genres.map(g => g.name).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCountriesString(countries: Array<{ name: string }> | undefined): string {
|
||||||
|
if (!countries || countries.length === 0) return '';
|
||||||
|
return countries.map(c => c.name).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMovie(mediaType: string | undefined): boolean {
|
||||||
|
if (!mediaType) return true;
|
||||||
|
return mediaType === 'movie' || mediaType === 'FILM';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTV(mediaType: string | undefined): boolean {
|
||||||
|
if (!mediaType) return false;
|
||||||
|
return mediaType === 'tv' || mediaType === 'TV_SERIES' || mediaType === 'MINI_SERIES';
|
||||||
|
}
|
||||||
@@ -7,18 +7,17 @@ export const favoritesAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Добавление в избранное
|
// Добавление в избранное
|
||||||
addFavorite(data: { mediaId: string; mediaType: string; title: string; posterPath?: string }) {
|
addFavorite(mediaId: string, mediaType: 'movie' | 'tv' = 'movie') {
|
||||||
const { mediaId, mediaType, ...rest } = data;
|
return neoApi.post(`/api/v1/favorites/${mediaId}?type=${mediaType}`);
|
||||||
return neoApi.post(`/api/v1/favorites/${mediaId}?mediaType=${mediaType}`, rest);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Удаление из избранного
|
// Удаление из избранного
|
||||||
removeFavorite(mediaId: string) {
|
removeFavorite(mediaId: string, mediaType: 'movie' | 'tv' = 'movie') {
|
||||||
return neoApi.delete(`/api/v1/favorites/${mediaId}`);
|
return neoApi.delete(`/api/v1/favorites/${mediaId}?type=${mediaType}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Проверка, добавлен ли в избранное
|
// Проверка статуса избранного
|
||||||
checkFavorite(mediaId: string) {
|
checkIsFavorite(mediaId: string, mediaType: 'movie' | 'tv' = 'movie') {
|
||||||
return neoApi.get(`/api/v1/favorites/check/${mediaId}`);
|
return neoApi.get(`/api/v1/favorites/${mediaId}/check?type=${mediaType}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
const favoriteSchema = new mongoose.Schema({
|
|
||||||
userId: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
mediaId: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
mediaType: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
enum: ['movie', 'tv']
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
posterPath: String,
|
|
||||||
createdAt: {
|
|
||||||
type: Date,
|
|
||||||
default: Date.now
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Составной индекс для уникальности комбинации userId, mediaId и mediaType
|
|
||||||
favoriteSchema.index({ userId: 1, mediaId: 1, mediaType: 1 }, { unique: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Favorite || mongoose.model('Favorite', favoriteSchema);
|
|
||||||
@@ -6,7 +6,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.neomovies.ru';
|
|||||||
export const neoApi = axios.create({
|
export const neoApi = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
@@ -26,6 +26,28 @@ neoApi.interceptors.request.use(
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем язык из настроек к запросам
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const settings = localStorage.getItem('settings');
|
||||||
|
if (settings) {
|
||||||
|
const parsedSettings = JSON.parse(settings);
|
||||||
|
const interfaceLanguage = parsedSettings.interfaceLanguage || 'ru';
|
||||||
|
|
||||||
|
// Добавляем параметр lang только если его еще нет
|
||||||
|
if (!config.params) {
|
||||||
|
config.params = {};
|
||||||
|
}
|
||||||
|
if (!config.params.lang && !config.params.language) {
|
||||||
|
config.params.lang = interfaceLanguage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading language from settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Логика для пагинации
|
// Логика для пагинации
|
||||||
if (config.params?.page) {
|
if (config.params?.page) {
|
||||||
const page = parseInt(config.params.page);
|
const page = parseInt(config.params.page);
|
||||||
@@ -33,6 +55,8 @@ neoApi.interceptors.request.use(
|
|||||||
config.params.page = 1;
|
config.params.page = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('🔵 Making request to:', config.baseURL + config.url, 'Params:', config.params);
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -41,15 +65,78 @@ neoApi.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Функция для обновления токена
|
||||||
|
const refreshToken = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(`${API_URL}/api/v1/auth/refresh`, {
|
||||||
|
refreshToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
const newAccessToken = data.accessToken;
|
||||||
|
const newRefreshToken = data.refreshToken;
|
||||||
|
|
||||||
|
if (newAccessToken && newRefreshToken) {
|
||||||
|
localStorage.setItem('token', newAccessToken);
|
||||||
|
localStorage.setItem('refreshToken', newRefreshToken);
|
||||||
|
return newAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh token:', error);
|
||||||
|
// Очищаем токены при ошибке обновления
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
localStorage.removeItem('userName');
|
||||||
|
localStorage.removeItem('userEmail');
|
||||||
|
|
||||||
|
// Отправляем событие для обновления UI
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('auth-changed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Перехватчик ответов
|
// Перехватчик ответов
|
||||||
neoApi.interceptors.response.use(
|
neoApi.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
if (response.data && response.data.success && response.data.data !== undefined) {
|
// Не обрабатываем изображения и плееры, которые могут иметь другую структуру
|
||||||
|
const url = response.config?.url || '';
|
||||||
|
const shouldUnwrap = !url.includes('/images/') &&
|
||||||
|
!url.includes('/players/');
|
||||||
|
|
||||||
|
if (shouldUnwrap && response.data && response.data.success && response.data.data !== undefined) {
|
||||||
response.data = response.data.data;
|
response.data = response.data.data;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// Проверяем на 401 ошибку и что запрос еще не был повторен
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const newToken = await refreshToken();
|
||||||
|
if (newToken) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return neoApi(originalRequest);
|
||||||
|
} else {
|
||||||
|
// Если не удалось обновить токен, перенаправляем на логин
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.error('❌ Response Error:', {
|
console.error('❌ Response Error:', {
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
statusText: error.response?.statusText,
|
statusText: error.response?.statusText,
|
||||||
@@ -64,8 +151,11 @@ neoApi.interceptors.response.use(
|
|||||||
|
|
||||||
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
|
export const getImageUrl = (path: string | null, size: string = 'w500'): string => {
|
||||||
if (!path) return '/images/placeholder.jpg';
|
if (!path) return '/images/placeholder.jpg';
|
||||||
if (path.startsWith('http')) {
|
// Всегда проксируем через наш API для обхода геоблока
|
||||||
return path;
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
// Передаём абсолютный URL как часть path (API теперь поддерживает это)
|
||||||
|
const encoded = encodeURIComponent(path);
|
||||||
|
return `${API_URL}/api/v1/images/${size}/${encoded}`;
|
||||||
}
|
}
|
||||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
|
return `${API_URL}/api/v1/images/${size}/${cleanPath}`;
|
||||||
@@ -76,13 +166,23 @@ export interface Genre {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
original_title?: string;
|
||||||
|
original_name?: string;
|
||||||
overview: string;
|
overview: string;
|
||||||
poster_path: string | null;
|
poster_path: string | null;
|
||||||
backdrop_path: string | null;
|
backdrop_path: string | null;
|
||||||
release_date: string;
|
release_date?: string;
|
||||||
|
first_air_date?: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
genre_ids: number[];
|
genre_ids: number[];
|
||||||
@@ -90,6 +190,26 @@ export interface Movie {
|
|||||||
genres?: Genre[];
|
genres?: Genre[];
|
||||||
popularity?: number;
|
popularity?: number;
|
||||||
media_type?: string;
|
media_type?: string;
|
||||||
|
adult?: boolean;
|
||||||
|
original_language?: string;
|
||||||
|
origin_country?: string[];
|
||||||
|
imdb_id?: string;
|
||||||
|
kinopoisk_id?: number;
|
||||||
|
nameRu?: string;
|
||||||
|
nameEn?: string;
|
||||||
|
nameOriginal?: string;
|
||||||
|
posterUrl?: string;
|
||||||
|
posterUrlPreview?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
ratingKinopoisk?: number;
|
||||||
|
ratingImdb?: number;
|
||||||
|
description?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
filmLength?: number;
|
||||||
|
filmId?: number;
|
||||||
|
type?: string;
|
||||||
|
year?: string | number;
|
||||||
|
countries?: Array<{ country: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MovieResponse {
|
export interface MovieResponse {
|
||||||
@@ -99,6 +219,112 @@ export interface MovieResponse {
|
|||||||
total_results: number;
|
total_results: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MovieDetails extends Movie {
|
||||||
|
title: string;
|
||||||
|
release_date: string;
|
||||||
|
runtime: number;
|
||||||
|
genres: Genre[];
|
||||||
|
tagline?: string;
|
||||||
|
budget?: number;
|
||||||
|
revenue?: number;
|
||||||
|
production_companies?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
logo_path: string | null;
|
||||||
|
origin_country: string;
|
||||||
|
}>;
|
||||||
|
production_countries?: Array<{
|
||||||
|
iso_3166_1: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
spoken_languages?: Array<{
|
||||||
|
iso_639_1: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
status?: string;
|
||||||
|
homepage?: string;
|
||||||
|
imdb_id?: string;
|
||||||
|
kinopoisk_id?: number;
|
||||||
|
external_ids?: {
|
||||||
|
imdb_id?: string;
|
||||||
|
kinopoisk_id?: number;
|
||||||
|
facebook_id?: string;
|
||||||
|
instagram_id?: string;
|
||||||
|
twitter_id?: string;
|
||||||
|
};
|
||||||
|
nameRu?: string;
|
||||||
|
nameEn?: string;
|
||||||
|
nameOriginal?: string;
|
||||||
|
posterUrl?: string;
|
||||||
|
posterUrlPreview?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
ratingKinopoisk?: number;
|
||||||
|
ratingImdb?: number;
|
||||||
|
description?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
filmLength?: number;
|
||||||
|
countries?: Array<{ country: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVShowDetails {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
original_name?: string;
|
||||||
|
overview: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
backdrop_path: string | null;
|
||||||
|
first_air_date: string;
|
||||||
|
last_air_date?: string;
|
||||||
|
vote_average: number;
|
||||||
|
vote_count: number;
|
||||||
|
genres: Genre[];
|
||||||
|
tagline?: string;
|
||||||
|
number_of_seasons: number;
|
||||||
|
number_of_episodes: number;
|
||||||
|
episode_run_time?: number[];
|
||||||
|
in_production?: boolean;
|
||||||
|
languages?: string[];
|
||||||
|
networks?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
logo_path: string | null;
|
||||||
|
origin_country: string;
|
||||||
|
}>;
|
||||||
|
production_companies?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
logo_path: string | null;
|
||||||
|
origin_country: string;
|
||||||
|
}>;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
external_ids?: {
|
||||||
|
imdb_id?: string;
|
||||||
|
kinopoisk_id?: number;
|
||||||
|
facebook_id?: string;
|
||||||
|
instagram_id?: string;
|
||||||
|
twitter_id?: string;
|
||||||
|
tvdb_id?: number;
|
||||||
|
};
|
||||||
|
imdb_id?: string;
|
||||||
|
kinopoisk_id?: number;
|
||||||
|
nameRu?: string;
|
||||||
|
nameEn?: string;
|
||||||
|
nameOriginal?: string;
|
||||||
|
posterUrl?: string;
|
||||||
|
posterUrlPreview?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
ratingKinopoisk?: number;
|
||||||
|
ratingImdb?: number;
|
||||||
|
description?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
serial?: boolean;
|
||||||
|
startYear?: number;
|
||||||
|
endYear?: number;
|
||||||
|
completed?: boolean;
|
||||||
|
countries?: Array<{ country: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TorrentResult {
|
export interface TorrentResult {
|
||||||
title: string;
|
title: string;
|
||||||
tracker: string;
|
tracker: string;
|
||||||
@@ -134,41 +360,24 @@ export interface AvailableSeasonsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const searchAPI = {
|
export const searchAPI = {
|
||||||
// Поиск фильмов
|
// Унифицированный мультипоиск
|
||||||
searchMovies(query: string, page = 1) {
|
async multiSearch(query: string, source: 'kp' | 'tmdb', page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
|
||||||
params: {
|
|
||||||
query,
|
|
||||||
page
|
|
||||||
},
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Поиск сериалов
|
|
||||||
searchTV(query: string, page = 1) {
|
|
||||||
return neoApi.get<MovieResponse>('/api/v1/tv/search', {
|
|
||||||
params: {
|
|
||||||
query,
|
|
||||||
page
|
|
||||||
},
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Мультипоиск (фильмы и сериалы) - новый эндпоинт
|
|
||||||
async multiSearch(query: string, page = 1) {
|
|
||||||
try {
|
try {
|
||||||
// Используем новый эндпоинт Go API
|
const response = await neoApi.get('/api/v1/search', {
|
||||||
const response = await neoApi.get<MovieResponse>('/search/multi', {
|
params: { query, source, page },
|
||||||
params: {
|
|
||||||
query,
|
|
||||||
page
|
|
||||||
},
|
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
// Unified API возвращает другую структуру, интерсептор может не развернуть её
|
||||||
return response;
|
// Проверяем, развернул ли интерсептор данные
|
||||||
|
if (response.data && response.data.success && response.data.data !== undefined) {
|
||||||
|
// Данные не развернуты, возвращаем полную структуру unified API
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// Данные уже развернуты интерсептором, оборачиваем обратно в unified формат
|
||||||
|
return {
|
||||||
|
data: response.data,
|
||||||
|
pagination: { page, totalPages: 1, totalResults: response.data?.length || 0, pageSize: response.data?.length || 0 }
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in multiSearch:', error);
|
console.error('Error in multiSearch:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -209,12 +418,12 @@ export const moviesAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение данных о фильме по его ID
|
// Получение данных о фильме по униф. ID (kp_123 / tmdb_123)
|
||||||
getMovie(id: string | number) {
|
getMovieBySourceId(sourceId: string) {
|
||||||
return neoApi.get(`/api/v1/movies/${id}`, { timeout: 30000 });
|
return neoApi.get(`/api/v1/movie/${sourceId}`, { timeout: 30000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Поиск фильмов
|
// Поиск фильмов (устаревший, используйте searchAPI.multiSearch)
|
||||||
searchMovies(query: string, page = 1) {
|
searchMovies(query: string, page = 1) {
|
||||||
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
return neoApi.get<MovieResponse>('/api/v1/movies/search', {
|
||||||
params: {
|
params: {
|
||||||
@@ -264,12 +473,12 @@ export const tvShowsAPI = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение данных о сериале по его ID
|
// Получение данных о сериале по униф. ID (kp_123 / tmdb_123)
|
||||||
getTVShow(id: string | number) {
|
getTVBySourceId(sourceId: string) {
|
||||||
return neoApi.get(`/api/v1/tv/${id}`, { timeout: 30000 });
|
return neoApi.get(`/api/v1/tv/${sourceId}`, { timeout: 30000 });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Поиск сериалов
|
// Поиск сериалов (устаревший, используйте searchAPI.multiSearch)
|
||||||
searchTVShows(query: string, page = 1) {
|
searchTVShows(query: string, page = 1) {
|
||||||
return neoApi.get('/api/v1/tv/search', {
|
return neoApi.get('/api/v1/tv/search', {
|
||||||
params: {
|
params: {
|
||||||
@@ -348,26 +557,30 @@ export const torrentsAPI = {
|
|||||||
export const categoriesAPI = {
|
export const categoriesAPI = {
|
||||||
// Получение всех категорий
|
// Получение всех категорий
|
||||||
getCategories() {
|
getCategories() {
|
||||||
return neoApi.get<{ categories: Category[] }>('/api/v1/categories');
|
return neoApi.get<Category[]>('/api/v1/categories');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение категории по ID
|
// Получение медиа по категории (унифицировано)
|
||||||
getCategory(id: number) {
|
getMediaByCategory(
|
||||||
return neoApi.get<Category>(`/api/v1/categories/${id}`);
|
categoryId: number,
|
||||||
|
type: 'movie' | 'tv' = 'movie',
|
||||||
|
page = 1,
|
||||||
|
language?: string,
|
||||||
|
source?: 'kp' | 'tmdb',
|
||||||
|
name?: string
|
||||||
|
) {
|
||||||
|
const params: any = { page, type };
|
||||||
|
if (language) params.language = language;
|
||||||
|
if (source) params.source = source;
|
||||||
|
if (name) params.name = name;
|
||||||
|
return neoApi.get(`/api/v1/categories/${categoryId}/media`, { params }).then(res => res.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение фильмов по категории
|
// Обратная совместимость (фильмы по категории)
|
||||||
getMoviesByCategory(categoryId: number, page = 1) {
|
getMoviesByCategory(categoryId: number, page = 1) {
|
||||||
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/movies`, {
|
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/movies`, {
|
||||||
params: { page }
|
params: { page }
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
// Получение сериалов по категории
|
|
||||||
getTVShowsByCategory(categoryId: number, page = 1) {
|
|
||||||
return neoApi.get<MovieResponse>(`/api/v1/categories/${categoryId}/tv`, {
|
|
||||||
params: { page }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
47
src/lib/unifiedTypes.ts
Normal file
47
src/lib/unifiedTypes.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface UnifiedGenre { id: string; name: string }
|
||||||
|
export interface UnifiedCastMember { id: string; name: string; character?: string }
|
||||||
|
export interface UnifiedExternalIDs { kp?: number | null; tmdb?: number | null; imdb: string }
|
||||||
|
export interface UnifiedSeason {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
name: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeCount: number;
|
||||||
|
releaseDate: string;
|
||||||
|
posterUrl: string;
|
||||||
|
}
|
||||||
|
export interface UnifiedContent {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
originalTitle: string;
|
||||||
|
description: string;
|
||||||
|
releaseDate: string;
|
||||||
|
endDate?: string | null;
|
||||||
|
type: 'movie' | 'tv';
|
||||||
|
genres: UnifiedGenre[];
|
||||||
|
rating: number;
|
||||||
|
posterUrl: string;
|
||||||
|
backdropUrl: string;
|
||||||
|
director: string;
|
||||||
|
cast: UnifiedCastMember[];
|
||||||
|
duration: number;
|
||||||
|
country: string;
|
||||||
|
language: string;
|
||||||
|
budget?: number | null;
|
||||||
|
revenue?: number | null;
|
||||||
|
imdbId?: string;
|
||||||
|
externalIds: UnifiedExternalIDs;
|
||||||
|
seasons?: UnifiedSeason[];
|
||||||
|
}
|
||||||
|
export interface UnifiedSearchItem {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
type: 'movie' | 'tv';
|
||||||
|
releaseDate: string;
|
||||||
|
posterUrl: string;
|
||||||
|
rating: number;
|
||||||
|
description: string;
|
||||||
|
externalIds: UnifiedExternalIDs;
|
||||||
|
}
|
||||||
323
src/locales/en.ts
Normal file
323
src/locales/en.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { Translation } from './ru';
|
||||||
|
|
||||||
|
export const en: Translation = {
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
movies: 'Movies',
|
||||||
|
series: 'Series',
|
||||||
|
categories: 'Categories',
|
||||||
|
search: 'Search',
|
||||||
|
favorites: 'Favorites',
|
||||||
|
profile: 'Profile',
|
||||||
|
settings: 'Settings',
|
||||||
|
login: 'Login',
|
||||||
|
logout: 'Logout',
|
||||||
|
menu: 'Menu',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Home page
|
||||||
|
home: {
|
||||||
|
popular: 'Popular',
|
||||||
|
trending: 'Trending',
|
||||||
|
topRated: 'Top Rated',
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
nowPlaying: 'Now Playing',
|
||||||
|
viewAll: 'View All',
|
||||||
|
loadMore: 'Load More',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Movie/TV details
|
||||||
|
details: {
|
||||||
|
overview: 'Overview',
|
||||||
|
cast: 'Cast',
|
||||||
|
crew: 'Crew',
|
||||||
|
seasons: 'Seasons',
|
||||||
|
episodes: 'Episodes',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
similar: 'Similar',
|
||||||
|
rating: 'Rating',
|
||||||
|
runtime: 'Runtime',
|
||||||
|
releaseDate: 'Release Date',
|
||||||
|
firstAirDate: 'First Air Date',
|
||||||
|
lastAirDate: 'Last Air Date',
|
||||||
|
status: 'Status',
|
||||||
|
genres: 'Genres',
|
||||||
|
production: 'Production',
|
||||||
|
budget: 'Budget',
|
||||||
|
revenue: 'Revenue',
|
||||||
|
addToFavorites: 'Add to Favorites',
|
||||||
|
removeFromFavorites: 'Remove from Favorites',
|
||||||
|
watchNow: 'Watch Now',
|
||||||
|
watchOnline: 'watch online',
|
||||||
|
watchTrailer: 'Watch Trailer',
|
||||||
|
year: 'Year',
|
||||||
|
country: 'Country',
|
||||||
|
torrents: 'Torrents',
|
||||||
|
noTorrents: 'No torrents found',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Torrents
|
||||||
|
torrents: {
|
||||||
|
title: 'Torrents',
|
||||||
|
notFound: 'No torrents found.',
|
||||||
|
loading: 'Loading torrents...',
|
||||||
|
copy: 'Copy',
|
||||||
|
download: 'Download',
|
||||||
|
copied: 'Copied!',
|
||||||
|
allSeasons: 'All seasons',
|
||||||
|
noMatches: 'No releases match the selected filters',
|
||||||
|
seeders: 'Seeders',
|
||||||
|
leechers: 'Leechers',
|
||||||
|
size: 'Size',
|
||||||
|
quality: 'Quality',
|
||||||
|
allQualities: 'All qualities',
|
||||||
|
releases: {
|
||||||
|
one: 'release',
|
||||||
|
few: 'releases',
|
||||||
|
many: 'releases',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Player
|
||||||
|
player: {
|
||||||
|
loading: 'Loading player...',
|
||||||
|
error: 'Loading error',
|
||||||
|
errorInfo: 'Failed to get player information.',
|
||||||
|
retry: 'Retry',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
selectPlayer: 'Select Player',
|
||||||
|
selectSeason: 'Season',
|
||||||
|
selectEpisode: 'Episode',
|
||||||
|
downloadHint: 'To enable downloading, select Lumex player in settings.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search movies and series...',
|
||||||
|
noResults: 'no results found',
|
||||||
|
searching: 'Searching...',
|
||||||
|
results: 'Results',
|
||||||
|
resultsFor: 'Search results for:',
|
||||||
|
totalResults: 'Total results:',
|
||||||
|
found: 'Found',
|
||||||
|
result_one: 'result',
|
||||||
|
result_few: 'results',
|
||||||
|
result_many: 'results',
|
||||||
|
loadingResults: 'Loading results...',
|
||||||
|
tryDifferent: 'Try changing your search query or using different keywords.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: {
|
||||||
|
title: 'Settings',
|
||||||
|
language: 'Language',
|
||||||
|
languageDescription: 'Select interface language',
|
||||||
|
playerLanguage: 'Player Language',
|
||||||
|
playerLanguageDescription: 'Select audio language for players',
|
||||||
|
russian: 'Russian',
|
||||||
|
english: 'English',
|
||||||
|
russianPlayers: 'Players with Russian audio',
|
||||||
|
englishPlayers: 'Players with English audio',
|
||||||
|
playerSettings: 'Player Settings',
|
||||||
|
playerSettingsDescription: 'Select the player that will be used by default for watching',
|
||||||
|
defaultPlayer: 'Default Player',
|
||||||
|
adBlockerWarning: 'AdBlocker is REQUIRED!',
|
||||||
|
adBlockerText: 'English players contain a lot of ads and popups. Without an ad blocker, using the player will be almost impossible.',
|
||||||
|
adBlockerRecommendation: 'Recommended: uBlock Origin or AdBlock Plus',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
login: 'Login',
|
||||||
|
register: 'Register',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
confirmPassword: 'Confirm Password',
|
||||||
|
forgotPassword: 'Forgot Password?',
|
||||||
|
noAccount: "Don't have an account?",
|
||||||
|
hasAccount: 'Already have an account?',
|
||||||
|
loginButton: 'Login',
|
||||||
|
registerButton: 'Register',
|
||||||
|
loggingIn: 'Logging in...',
|
||||||
|
registering: 'Registering...',
|
||||||
|
continueWithGoogle: 'Continue with Google',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
profile: {
|
||||||
|
title: 'Profile',
|
||||||
|
watchHistory: 'Watch History',
|
||||||
|
favorites: 'Favorites',
|
||||||
|
settings: 'Settings',
|
||||||
|
accountManagement: 'Account Management',
|
||||||
|
logout: 'Logout',
|
||||||
|
dangerZone: 'Danger Zone',
|
||||||
|
deleteAccount: 'Delete Account',
|
||||||
|
deleteWarning: 'This action cannot be undone. All your data, including favorites, will be deleted.',
|
||||||
|
confirmDelete: 'Confirm Account Deletion',
|
||||||
|
confirmDeleteText: 'Are you sure you want to permanently delete your account? All your data, including favorites and reactions, will be irreversibly deleted. This action cannot be undone.',
|
||||||
|
accountDeleted: 'Account successfully deleted.',
|
||||||
|
deleteFailed: 'Failed to delete account. Please try again.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
verify: {
|
||||||
|
title: 'Email Verification',
|
||||||
|
sentCode: 'We sent a verification code to',
|
||||||
|
enterCode: 'Enter code',
|
||||||
|
verify: 'Verify',
|
||||||
|
verifying: 'Verifying...',
|
||||||
|
resendCode: 'Resend code',
|
||||||
|
resendIn: 'in',
|
||||||
|
emailError: 'Failed to get email for verification',
|
||||||
|
resendFailed: 'Failed to send code',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Favorites
|
||||||
|
favorites: {
|
||||||
|
title: 'Favorites',
|
||||||
|
empty: 'You have no favorites yet',
|
||||||
|
emptyDescription: 'Add movies and series to favorites to see them here',
|
||||||
|
goToMovies: 'Go to Movies',
|
||||||
|
addedToFavorites: 'Added to favorites',
|
||||||
|
removedFromFavorites: 'Removed from favorites',
|
||||||
|
loginRequired: 'Please login to add favorites',
|
||||||
|
addToFavorites: 'Add to Favorites',
|
||||||
|
inFavorites: 'In Favorites',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Terms page
|
||||||
|
terms: {
|
||||||
|
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.',
|
||||||
|
declineAlert: 'You cannot use the site without agreeing to the terms.',
|
||||||
|
|
||||||
|
section1Title: '1. General Provisions',
|
||||||
|
section1Text: '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.',
|
||||||
|
|
||||||
|
section2Title: '2. Service Description',
|
||||||
|
section2Text: '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.',
|
||||||
|
|
||||||
|
section3Title: '3. Liability',
|
||||||
|
section3Text: 'The site is not responsible for:',
|
||||||
|
section3List: [
|
||||||
|
'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.'
|
||||||
|
],
|
||||||
|
section3After: 'All responsibility for using the content lies solely with the user. Use of third-party sources is at your own risk.',
|
||||||
|
|
||||||
|
section4Title: '4. Registration and Personal Data',
|
||||||
|
section4Text: '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.',
|
||||||
|
|
||||||
|
section5Title: '5. Changes to the Agreement',
|
||||||
|
section5Text: '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.',
|
||||||
|
|
||||||
|
section6Title: '6. Final Provisions',
|
||||||
|
section6Text: '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.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
categories: {
|
||||||
|
action: 'Action',
|
||||||
|
adventure: 'Adventure',
|
||||||
|
animation: 'Animation',
|
||||||
|
comedy: 'Comedy',
|
||||||
|
crime: 'Crime',
|
||||||
|
documentary: 'Documentary',
|
||||||
|
drama: 'Drama',
|
||||||
|
family: 'Family',
|
||||||
|
fantasy: 'Fantasy',
|
||||||
|
history: 'History',
|
||||||
|
horror: 'Horror',
|
||||||
|
music: 'Music',
|
||||||
|
mystery: 'Mystery',
|
||||||
|
romance: 'Romance',
|
||||||
|
scienceFiction: 'Science Fiction',
|
||||||
|
tvMovie: 'TV Movie',
|
||||||
|
thriller: 'Thriller',
|
||||||
|
war: 'War',
|
||||||
|
western: 'Western',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Terms
|
||||||
|
terms: {
|
||||||
|
title: 'Terms of Service',
|
||||||
|
selectLanguage: 'Select Language / Выберите язык',
|
||||||
|
accept: 'Accept',
|
||||||
|
decline: 'Decline',
|
||||||
|
lastUpdated: 'Last Updated',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
loading: 'Loading...',
|
||||||
|
error: 'Error',
|
||||||
|
success: 'Success',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
delete: 'Delete',
|
||||||
|
edit: 'Edit',
|
||||||
|
close: 'Close',
|
||||||
|
back: 'Back',
|
||||||
|
next: 'Next',
|
||||||
|
previous: 'Previous',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
or: 'or',
|
||||||
|
minutes: 'min',
|
||||||
|
pageNotFound: 'Page not found',
|
||||||
|
movieNotFound: 'Movie not found',
|
||||||
|
tvNotFound: 'TV show not found',
|
||||||
|
failedToLoad: 'Failed to load',
|
||||||
|
unknownError: 'Unknown error',
|
||||||
|
untitled: 'Untitled',
|
||||||
|
backToCategories: 'Back to Categories',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
categories: {
|
||||||
|
title: 'Categories',
|
||||||
|
selectCategory: 'Select a category to view movies',
|
||||||
|
failedToLoadCategories: 'Failed to load categories',
|
||||||
|
errorLoadingCategories: 'Error loading categories',
|
||||||
|
errorLoadingMovies: 'Error loading movies',
|
||||||
|
unknownCategory: 'Unknown category',
|
||||||
|
noMoviesInCategory: 'No movies in this category.',
|
||||||
|
names: {
|
||||||
|
12: 'Adventure',
|
||||||
|
10751: 'Family',
|
||||||
|
10752: 'War',
|
||||||
|
10762: 'Kids',
|
||||||
|
10764: 'Reality',
|
||||||
|
10749: 'Romance',
|
||||||
|
28: 'Action',
|
||||||
|
80: 'Crime',
|
||||||
|
18: 'Drama',
|
||||||
|
14: 'Fantasy',
|
||||||
|
27: 'Horror',
|
||||||
|
10402: 'Music',
|
||||||
|
10770: 'TV Movie',
|
||||||
|
16: 'Animation',
|
||||||
|
99: 'Documentary',
|
||||||
|
878: 'Science Fiction',
|
||||||
|
37: 'Western',
|
||||||
|
10765: 'Sci-Fi & Fantasy',
|
||||||
|
10767: 'Talk',
|
||||||
|
10768: 'War & Politics',
|
||||||
|
9648: 'Mystery',
|
||||||
|
35: 'Comedy',
|
||||||
|
36: 'History',
|
||||||
|
53: 'Thriller',
|
||||||
|
10759: 'Action & Adventure',
|
||||||
|
10763: 'News',
|
||||||
|
10766: 'Soap',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/locales/index.ts
Normal file
2
src/locales/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ru, type Translation } from './ru';
|
||||||
|
export { en } from './en';
|
||||||
323
src/locales/ru.ts
Normal file
323
src/locales/ru.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
export const ru = {
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
home: 'Главная',
|
||||||
|
movies: 'Фильмы',
|
||||||
|
series: 'Сериалы',
|
||||||
|
categories: 'Категории',
|
||||||
|
search: 'Поиск',
|
||||||
|
favorites: 'Избранное',
|
||||||
|
profile: 'Профиль',
|
||||||
|
settings: 'Настройки',
|
||||||
|
login: 'Войти',
|
||||||
|
logout: 'Выйти',
|
||||||
|
menu: 'Меню',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Home page
|
||||||
|
home: {
|
||||||
|
popular: 'Популярные',
|
||||||
|
trending: 'В тренде',
|
||||||
|
topRated: 'Топ рейтинга',
|
||||||
|
upcoming: 'Скоро',
|
||||||
|
nowPlaying: 'Новинки',
|
||||||
|
viewAll: 'Смотреть все',
|
||||||
|
loadMore: 'Загрузить ещё',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Movie/TV details
|
||||||
|
details: {
|
||||||
|
overview: 'Описание',
|
||||||
|
cast: 'Актёры',
|
||||||
|
crew: 'Съёмочная группа',
|
||||||
|
seasons: 'Сезоны',
|
||||||
|
episodes: 'Серии',
|
||||||
|
recommendations: 'Рекомендации',
|
||||||
|
similar: 'Похожие',
|
||||||
|
rating: 'Рейтинг',
|
||||||
|
runtime: 'Длительность',
|
||||||
|
releaseDate: 'Дата выхода',
|
||||||
|
firstAirDate: 'Первый эфир',
|
||||||
|
lastAirDate: 'Последний эфир',
|
||||||
|
status: 'Статус',
|
||||||
|
genres: 'Жанры',
|
||||||
|
production: 'Производство',
|
||||||
|
budget: 'Бюджет',
|
||||||
|
revenue: 'Сборы',
|
||||||
|
addToFavorites: 'В избранное',
|
||||||
|
removeFromFavorites: 'Из избранного',
|
||||||
|
watchNow: 'Смотреть',
|
||||||
|
watchOnline: 'смотреть онлайн',
|
||||||
|
watchTrailer: 'Трейлер',
|
||||||
|
year: 'Год',
|
||||||
|
country: 'Страна',
|
||||||
|
torrents: 'Торренты',
|
||||||
|
noTorrents: 'Торренты не найдены',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Torrents
|
||||||
|
torrents: {
|
||||||
|
title: 'Торренты',
|
||||||
|
notFound: 'Торренты не найдены.',
|
||||||
|
loading: 'Загрузка торрентов...',
|
||||||
|
copy: 'Копировать',
|
||||||
|
download: 'Скачать',
|
||||||
|
copied: 'Скопировано!',
|
||||||
|
allSeasons: 'Все сезоны',
|
||||||
|
noMatches: 'Нет раздач, соответствующих выбранным фильтрам',
|
||||||
|
seeders: 'Сидеры',
|
||||||
|
leechers: 'Личеры',
|
||||||
|
size: 'Размер',
|
||||||
|
quality: 'Качество',
|
||||||
|
allQualities: 'Все качества',
|
||||||
|
releases: {
|
||||||
|
one: 'раздача',
|
||||||
|
few: 'раздачи',
|
||||||
|
many: 'раздач',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Player
|
||||||
|
player: {
|
||||||
|
loading: 'Загрузка плеера...',
|
||||||
|
error: 'Ошибка загрузки',
|
||||||
|
errorInfo: 'Не удалось получить информацию для плеера.',
|
||||||
|
retry: 'Повторить',
|
||||||
|
fullscreen: 'Полный экран',
|
||||||
|
selectPlayer: 'Выбрать плеер',
|
||||||
|
selectSeason: 'Сезон',
|
||||||
|
selectEpisode: 'Серия',
|
||||||
|
downloadHint: 'Для возможности скачивания фильма выберите плеер Lumex в настройках.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search: {
|
||||||
|
placeholder: 'Поиск фильмов и сериалов...',
|
||||||
|
noResults: 'ничего не найдено',
|
||||||
|
searching: 'Поиск...',
|
||||||
|
results: 'Результаты',
|
||||||
|
resultsFor: 'Результаты поиска для:',
|
||||||
|
totalResults: 'Найдено результатов:',
|
||||||
|
found: 'Найдено',
|
||||||
|
result_one: 'результат',
|
||||||
|
result_few: 'результата',
|
||||||
|
result_many: 'результатов',
|
||||||
|
loadingResults: 'Загрузка результатов...',
|
||||||
|
tryDifferent: 'Попробуйте изменить поисковый запрос или использовать другие ключевые слова.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: {
|
||||||
|
title: 'Настройки',
|
||||||
|
language: 'Язык',
|
||||||
|
languageDescription: 'Выберите язык интерфейса',
|
||||||
|
playerLanguage: 'Язык плееров',
|
||||||
|
playerLanguageDescription: 'Выберите язык озвучки для плееров',
|
||||||
|
russian: 'Русский',
|
||||||
|
english: 'Английский',
|
||||||
|
russianPlayers: 'Плееры с русской озвучкой',
|
||||||
|
englishPlayers: 'Плееры с английской озвучкой',
|
||||||
|
playerSettings: 'Настройки плеера',
|
||||||
|
playerSettingsDescription: 'Выберите плеер, который будет использоваться по умолчанию для просмотра',
|
||||||
|
defaultPlayer: 'Плеер по умолчанию',
|
||||||
|
adBlockerWarning: 'ОБЯЗАТЕЛЬНО используйте AdBlocker!',
|
||||||
|
adBlockerText: 'Английские плееры содержат большое количество рекламы и всплывающих окон. Без блокировщика рекламы пользоваться плеером будет практически невозможно.',
|
||||||
|
adBlockerRecommendation: 'Рекомендуем: uBlock Origin или AdBlock Plus',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
login: 'Вход',
|
||||||
|
register: 'Регистрация',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Пароль',
|
||||||
|
confirmPassword: 'Подтвердите пароль',
|
||||||
|
forgotPassword: 'Забыли пароль?',
|
||||||
|
noAccount: 'Нет аккаунта?',
|
||||||
|
hasAccount: 'Уже есть аккаунт?',
|
||||||
|
loginButton: 'Войти',
|
||||||
|
registerButton: 'Зарегистрироваться',
|
||||||
|
loggingIn: 'Вход...',
|
||||||
|
registering: 'Регистрация...',
|
||||||
|
continueWithGoogle: 'Продолжить с Google',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
profile: {
|
||||||
|
title: 'Профиль',
|
||||||
|
watchHistory: 'История просмотров',
|
||||||
|
favorites: 'Избранное',
|
||||||
|
settings: 'Настройки',
|
||||||
|
accountManagement: 'Управление аккаунтом',
|
||||||
|
logout: 'Выйти из аккаунта',
|
||||||
|
dangerZone: 'Опасная зона',
|
||||||
|
deleteAccount: 'Удалить аккаунт',
|
||||||
|
deleteWarning: 'Это действие нельзя будет отменить. Все ваши данные, включая избранное, будут удалены.',
|
||||||
|
confirmDelete: 'Подтвердите удаление аккаунта',
|
||||||
|
confirmDeleteText: 'Вы уверены, что хотите навсегда удалить свой аккаунт? Все ваши данные, включая избранное и реакции, будут безвозвратно удалены. Это действие нельзя будет отменить.',
|
||||||
|
accountDeleted: 'Аккаунт успешно удален.',
|
||||||
|
deleteFailed: 'Не удалось удалить аккаунт. Попробуйте снова.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
verify: {
|
||||||
|
title: 'Подтверждение email',
|
||||||
|
sentCode: 'Мы отправили код подтверждения на',
|
||||||
|
enterCode: 'Введите код',
|
||||||
|
verify: 'Подтвердить',
|
||||||
|
verifying: 'Проверка...',
|
||||||
|
resendCode: 'Отправить код повторно',
|
||||||
|
resendIn: 'через',
|
||||||
|
emailError: 'Не удалось получить email для подтверждения',
|
||||||
|
resendFailed: 'Не удалось отправить код',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Favorites
|
||||||
|
favorites: {
|
||||||
|
title: 'Избранное',
|
||||||
|
empty: 'У вас пока нет избранного',
|
||||||
|
emptyDescription: 'Добавьте фильмы и сериалы в избранное, чтобы они появились здесь',
|
||||||
|
goToMovies: 'Перейти к фильмам',
|
||||||
|
addedToFavorites: 'Добавлено в избранное',
|
||||||
|
removedFromFavorites: 'Удалено из избранного',
|
||||||
|
loginRequired: 'Для добавления в избранное необходимо авторизоваться',
|
||||||
|
addToFavorites: 'В избранное',
|
||||||
|
inFavorites: 'В избранном',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Terms page
|
||||||
|
terms: {
|
||||||
|
title: 'Пользовательское соглашение Neo Movies',
|
||||||
|
subtitle: 'Пожалуйста, внимательно ознакомьтесь с условиями использования',
|
||||||
|
selectLanguage: 'Выберите язык / Select Language',
|
||||||
|
accept: 'Принимаю условия',
|
||||||
|
decline: 'Отклонить',
|
||||||
|
footer: '© 2025 Neo Movies. Все права защищены.',
|
||||||
|
declineAlert: 'Вы не можете использовать сайт без согласия с условиями.',
|
||||||
|
|
||||||
|
section1Title: '1. Общие положения',
|
||||||
|
section1Text: 'Использование сайта NeoMovies (https://neo-movies.vercel.app, https://neomovies.ru) возможно только при полном согласии с условиями настоящего Пользовательского соглашения. Несогласие с любыми положениями соглашения означает, что вы не имеете права использовать данный сайт и должны прекратить доступ к нему.',
|
||||||
|
|
||||||
|
section2Title: '2. Описание сервиса',
|
||||||
|
section2Text: 'NeoMovies предоставляет доступ к информации о фильмах и сериалах с использованием API TMDB. Видео воспроизводятся с использованием сторонних видеохостингов и балансеров. Сайт не хранит и не распространяет видеофайлы. Мы выступаем исключительно в роли посредника между пользователем и внешними сервисами.\n\nНекоторая информация о доступности контента также может быть получена из общедоступных децентрализованных источников, включая magnet-ссылки. Сайт не распространяет файлы и не является участником пиринговых сетей.',
|
||||||
|
|
||||||
|
section3Title: '3. Ответственность',
|
||||||
|
section3Text: 'Сайт не несёт ответственности за:',
|
||||||
|
section3List: [
|
||||||
|
'точность или легальность предоставленного сторонними плеерами контента;',
|
||||||
|
'возможные нарушения авторских прав со стороны балансеров;',
|
||||||
|
'действия пользователей, связанные с просмотром, загрузкой или распространением контента.'
|
||||||
|
],
|
||||||
|
section3After: 'Вся ответственность за использование контента лежит исключительно на пользователе. Использование сторонних источников осуществляется на ваш собственный риск.',
|
||||||
|
|
||||||
|
section4Title: '4. Регистрация и персональные данные',
|
||||||
|
section4Text: 'Сайт собирает только минимальный набор данных: имя, email и пароль — исключительно для сохранения избранного. Пароли шифруются и хранятся безопасно. Мы не передаём ваши данные третьим лицам и не используем их в маркетинговых целях.\n\nИсходный код сайта полностью открыт и доступен для проверки в публичном репозитории, что обеспечивает максимальную прозрачность и возможность независимого аудита безопасности и обработки данных.\n\nПользователь подтверждает, что ему исполнилось 16 лет либо он получил разрешение от законного представителя.',
|
||||||
|
|
||||||
|
section5Title: '5. Изменения в соглашении',
|
||||||
|
section5Text: 'Мы оставляем за собой право вносить изменения в настоящее соглашение. Продолжение использования сервиса после внесения изменений означает ваше согласие с обновлёнными условиями.',
|
||||||
|
|
||||||
|
section6Title: '6. Заключительные положения',
|
||||||
|
section6Text: 'Настоящее соглашение вступает в силу с момента вашего согласия с его условиями и действует бессрочно.\n\nЕсли вы не согласны с какими-либо положениями данного соглашения, вы должны немедленно прекратить использование сервиса.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
categories: {
|
||||||
|
action: 'Боевик',
|
||||||
|
adventure: 'Приключения',
|
||||||
|
animation: 'Анимация',
|
||||||
|
comedy: 'Комедия',
|
||||||
|
crime: 'Криминал',
|
||||||
|
documentary: 'Документальный',
|
||||||
|
drama: 'Драма',
|
||||||
|
family: 'Семейный',
|
||||||
|
fantasy: 'Фантастика',
|
||||||
|
history: 'История',
|
||||||
|
horror: 'Ужасы',
|
||||||
|
music: 'Музыка',
|
||||||
|
mystery: 'Детектив',
|
||||||
|
romance: 'Романтика',
|
||||||
|
scienceFiction: 'Научная фантастика',
|
||||||
|
tvMovie: 'ТВ фильм',
|
||||||
|
thriller: 'Триллер',
|
||||||
|
war: 'Военный',
|
||||||
|
western: 'Вестерн',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Terms
|
||||||
|
terms: {
|
||||||
|
title: 'Пользовательское соглашение',
|
||||||
|
selectLanguage: 'Выберите язык / Select Language',
|
||||||
|
accept: 'Принять',
|
||||||
|
decline: 'Отклонить',
|
||||||
|
lastUpdated: 'Последнее обновление',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
loading: 'Загрузка...',
|
||||||
|
error: 'Ошибка',
|
||||||
|
success: 'Успешно',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
save: 'Сохранить',
|
||||||
|
delete: 'Удалить',
|
||||||
|
edit: 'Редактировать',
|
||||||
|
close: 'Закрыть',
|
||||||
|
back: 'Назад',
|
||||||
|
next: 'Далее',
|
||||||
|
previous: 'Назад',
|
||||||
|
confirm: 'Подтвердить',
|
||||||
|
yes: 'Да',
|
||||||
|
no: 'Нет',
|
||||||
|
or: 'или',
|
||||||
|
minutes: 'мин',
|
||||||
|
pageNotFound: 'Страница не найдена',
|
||||||
|
movieNotFound: 'Фильм не найден',
|
||||||
|
tvNotFound: 'Сериал не найден',
|
||||||
|
failedToLoad: 'Не удалось загрузить',
|
||||||
|
unknownError: 'Неизвестная ошибка',
|
||||||
|
untitled: 'Без названия',
|
||||||
|
backToCategories: 'Назад к категориям',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
categories: {
|
||||||
|
title: 'Категории',
|
||||||
|
selectCategory: 'Выберите категорию для просмотра фильмов',
|
||||||
|
failedToLoadCategories: 'Не удалось загрузить категории',
|
||||||
|
errorLoadingCategories: 'Ошибка при загрузке категорий',
|
||||||
|
errorLoadingMovies: 'Ошибка при загрузке фильмов',
|
||||||
|
unknownCategory: 'Неизвестная категория',
|
||||||
|
noMoviesInCategory: 'Нет фильмов в этой категории.',
|
||||||
|
names: {
|
||||||
|
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: 'Мыльная опера',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Translation = typeof ru;
|
||||||
240
src/types/kinopoisk.ts
Normal file
240
src/types/kinopoisk.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
export interface KPCountry {
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPGenre {
|
||||||
|
genre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPFilm {
|
||||||
|
kinopoiskId: number;
|
||||||
|
imdbId: string | null;
|
||||||
|
nameRu: string | null;
|
||||||
|
nameEn: string | null;
|
||||||
|
nameOriginal: string | null;
|
||||||
|
countries: KPCountry[];
|
||||||
|
genres: KPGenre[];
|
||||||
|
ratingKinopoisk: number | null;
|
||||||
|
ratingImdb: number | null;
|
||||||
|
year: number | null;
|
||||||
|
type: string;
|
||||||
|
posterUrl: string;
|
||||||
|
posterUrlPreview: string;
|
||||||
|
coverUrl: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
shortDescription: string | null;
|
||||||
|
slogan: string | null;
|
||||||
|
filmLength: number | null;
|
||||||
|
ratingAgeLimits: string | null;
|
||||||
|
startYear: number | null;
|
||||||
|
endYear: number | null;
|
||||||
|
serial: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
ratingKinopoiskVoteCount: number;
|
||||||
|
ratingImdbVoteCount: number;
|
||||||
|
webUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPFilmShort {
|
||||||
|
filmId: number;
|
||||||
|
nameRu: string | null;
|
||||||
|
nameEn: string | null;
|
||||||
|
type: string;
|
||||||
|
year: string;
|
||||||
|
description: string | null;
|
||||||
|
filmLength: string | null;
|
||||||
|
countries: KPCountry[];
|
||||||
|
genres: KPGenre[];
|
||||||
|
rating: string | null;
|
||||||
|
ratingVoteCount: number;
|
||||||
|
posterUrl: string;
|
||||||
|
posterUrlPreview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KPSearchResponse {
|
||||||
|
keyword: string;
|
||||||
|
pagesCount: number;
|
||||||
|
films: KPFilmShort[];
|
||||||
|
searchFilmsCountResult: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedMovie {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
originalTitle: string;
|
||||||
|
overview: string;
|
||||||
|
posterPath: string;
|
||||||
|
backdropPath: string;
|
||||||
|
releaseDate: string;
|
||||||
|
voteAverage: number;
|
||||||
|
voteCount: number;
|
||||||
|
popularity: number;
|
||||||
|
genres: Array<{ id: number; name: string }>;
|
||||||
|
countries: Array<{ name: string }>;
|
||||||
|
runtime: number | null;
|
||||||
|
imdbId: string | null;
|
||||||
|
kinopoiskId: number | null;
|
||||||
|
type?: string;
|
||||||
|
isSerial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKPFilm(kpFilm: KPFilm | null): NormalizedMovie | null {
|
||||||
|
if (!kpFilm) return null;
|
||||||
|
|
||||||
|
const title = kpFilm.nameRu || kpFilm.nameEn || kpFilm.nameOriginal || 'Без названия';
|
||||||
|
const originalTitle = kpFilm.nameOriginal || kpFilm.nameEn || kpFilm.nameRu || '';
|
||||||
|
const overview = kpFilm.description || kpFilm.shortDescription || '';
|
||||||
|
const posterPath = kpFilm.posterUrlPreview || kpFilm.posterUrl || '';
|
||||||
|
const backdropPath = kpFilm.coverUrl || '';
|
||||||
|
const releaseDate = kpFilm.year ? `${kpFilm.year}-01-01` : '';
|
||||||
|
const voteAverage = kpFilm.ratingKinopoisk || kpFilm.ratingImdb || 0;
|
||||||
|
const voteCount = kpFilm.ratingKinopoiskVoteCount || kpFilm.ratingImdbVoteCount || 0;
|
||||||
|
|
||||||
|
const genres = (kpFilm.genres || []).map((g, index) => ({
|
||||||
|
id: index,
|
||||||
|
name: g.genre
|
||||||
|
}));
|
||||||
|
|
||||||
|
const countries = (kpFilm.countries || []).map(c => ({
|
||||||
|
name: c.country
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: kpFilm.kinopoiskId,
|
||||||
|
title,
|
||||||
|
originalTitle,
|
||||||
|
overview,
|
||||||
|
posterPath,
|
||||||
|
backdropPath,
|
||||||
|
releaseDate,
|
||||||
|
voteAverage,
|
||||||
|
voteCount,
|
||||||
|
popularity: voteAverage * 100,
|
||||||
|
genres,
|
||||||
|
countries,
|
||||||
|
runtime: kpFilm.filmLength,
|
||||||
|
imdbId: kpFilm.imdbId,
|
||||||
|
kinopoiskId: kpFilm.kinopoiskId,
|
||||||
|
type: kpFilm.type,
|
||||||
|
isSerial: kpFilm.serial
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKPFilmShort(kpFilm: KPFilmShort | null): NormalizedMovie | null {
|
||||||
|
if (!kpFilm) return null;
|
||||||
|
|
||||||
|
const title = kpFilm.nameRu || kpFilm.nameEn || 'Без названия';
|
||||||
|
const originalTitle = kpFilm.nameEn || kpFilm.nameRu || '';
|
||||||
|
const overview = kpFilm.description || '';
|
||||||
|
const posterPath = kpFilm.posterUrlPreview || kpFilm.posterUrl || '';
|
||||||
|
const releaseDate = kpFilm.year ? `${kpFilm.year}-01-01` : '';
|
||||||
|
const voteAverage = kpFilm.rating ? parseFloat(kpFilm.rating) : 0;
|
||||||
|
|
||||||
|
const genres = (kpFilm.genres || []).map((g, index) => ({
|
||||||
|
id: index,
|
||||||
|
name: g.genre
|
||||||
|
}));
|
||||||
|
|
||||||
|
const countries = (kpFilm.countries || []).map(c => ({
|
||||||
|
name: c.country
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime = kpFilm.filmLength ? parseInt(kpFilm.filmLength) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: kpFilm.filmId,
|
||||||
|
title,
|
||||||
|
originalTitle,
|
||||||
|
overview,
|
||||||
|
posterPath,
|
||||||
|
backdropPath: '',
|
||||||
|
releaseDate,
|
||||||
|
voteAverage,
|
||||||
|
voteCount: kpFilm.ratingVoteCount,
|
||||||
|
popularity: voteAverage * 100,
|
||||||
|
genres,
|
||||||
|
countries,
|
||||||
|
runtime,
|
||||||
|
imdbId: null,
|
||||||
|
kinopoiskId: kpFilm.filmId,
|
||||||
|
type: kpFilm.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieTitle(movie: any): string {
|
||||||
|
if (movie.nameRu) return movie.nameRu;
|
||||||
|
if (movie.nameEn) return movie.nameEn;
|
||||||
|
if (movie.nameOriginal) return movie.nameOriginal;
|
||||||
|
if (movie.title) return movie.title;
|
||||||
|
if (movie.name) return movie.name;
|
||||||
|
return 'Без названия';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieOriginalTitle(movie: any): string {
|
||||||
|
if (movie.nameOriginal) return movie.nameOriginal;
|
||||||
|
if (movie.nameEn) return movie.nameEn;
|
||||||
|
if (movie.original_title) return movie.original_title;
|
||||||
|
if (movie.original_name) return movie.original_name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoviePoster(movie: any): string {
|
||||||
|
if (movie.posterUrlPreview) return movie.posterUrlPreview;
|
||||||
|
if (movie.posterUrl) return movie.posterUrl;
|
||||||
|
if (movie.poster_path) return movie.poster_path;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieBackdrop(movie: any): string {
|
||||||
|
if (movie.coverUrl) return movie.coverUrl;
|
||||||
|
if (movie.backdrop_path) return movie.backdrop_path;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieRating(movie: any): number {
|
||||||
|
if (movie.ratingKinopoisk) return movie.ratingKinopoisk;
|
||||||
|
if (movie.ratingImdb) return movie.ratingImdb;
|
||||||
|
if (movie.rating && typeof movie.rating === 'string') return parseFloat(movie.rating);
|
||||||
|
if (movie.vote_average) return movie.vote_average;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieYear(movie: any): string {
|
||||||
|
if (movie.year) {
|
||||||
|
return typeof movie.year === 'number' ? movie.year.toString() : movie.year;
|
||||||
|
}
|
||||||
|
if (movie.release_date) {
|
||||||
|
return movie.release_date.split('-')[0];
|
||||||
|
}
|
||||||
|
if (movie.first_air_date) {
|
||||||
|
return movie.first_air_date.split('-')[0];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovieOverview(movie: any): string {
|
||||||
|
if (movie.description) return movie.description;
|
||||||
|
if (movie.shortDescription) return movie.shortDescription;
|
||||||
|
if (movie.overview) return movie.overview;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKPData(movie: any): boolean {
|
||||||
|
return !!(
|
||||||
|
movie.kinopoiskId ||
|
||||||
|
movie.filmId ||
|
||||||
|
movie.nameRu ||
|
||||||
|
movie.nameEn ||
|
||||||
|
movie.nameOriginal ||
|
||||||
|
movie.posterUrlPreview ||
|
||||||
|
movie.posterUrl ||
|
||||||
|
movie.ratingKinopoisk ||
|
||||||
|
(movie.poster_path && typeof movie.poster_path === 'string' && movie.poster_path.includes('kinopoiskapiunofficial'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTMDBData(movie: any): boolean {
|
||||||
|
if (isKPData(movie)) return false;
|
||||||
|
return !!(movie.poster_path || movie.backdrop_path || movie.vote_average);
|
||||||
|
}
|
||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user