full change ui and small fixes

This commit is contained in:
2025-07-08 00:15:55 +03:00
parent 4aad0c8d48
commit bc2a4a623f
42 changed files with 10832 additions and 3337 deletions

View File

@@ -1,155 +1,21 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import styled from 'styled-components';
import { categoriesAPI } from '@/lib/api';
import { useState, useEffect, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { categoriesAPI, Movie, TVShow } from '@/lib/api';
import MovieCard from '@/components/MovieCard';
import { Movie, Category } from '@/lib/api';
// Styled Components
const Container = styled.div`
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1rem;
`;
const Title = styled.h1`
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1.5rem;
color: #fff;
`;
const ButtonsContainer = styled.div`
display: flex;
gap: 1rem;
margin-bottom: 2rem;
@media (max-width: 640px) {
flex-direction: column;
gap: 0.5rem;
}
`;
const TabButton = styled.button<{ $active?: boolean }>`
padding: 0.5rem 1.5rem;
border-radius: 0.375rem;
font-size: 0.875rem;
background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'};
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'};
border: none;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'};
}
`;
const BackButton = styled.button`
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
`;
const MediaGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1.5rem;
@media (min-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
`;
const PaginationContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin: 2rem 0;
`;
const PaginationButton = styled.button<{ $active?: boolean }>`
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'};
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'};
border: none;
cursor: pointer;
transition: all 0.2s;
min-width: 2.5rem;
&:hover {
background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`;
const Spinner = styled.div`
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: #3182ce;
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const ErrorMessage = styled.div`
color: #fc8181;
text-align: center;
padding: 2rem;
background: rgba(252, 129, 129, 0.1);
border-radius: 0.5rem;
margin: 2rem 0;
`;
import { ArrowLeft, Loader2 } from 'lucide-react';
type MediaType = 'movies' | 'tv';
function CategoryPage() {
// Используем хук useParams вместо props
const params = useParams();
const router = useRouter();
const categoryId = parseInt(params.id as string);
const [category, setCategory] = useState<Category | null>(null);
const [categoryName, setCategoryName] = useState<string>('');
const [mediaType, setMediaType] = useState<MediaType>('movies');
const [movies, setMovies] = useState<Movie[]>([]);
const [tvShows, setTvShows] = useState<Movie[]>([]);
const [items, setItems] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
@@ -157,328 +23,211 @@ function CategoryPage() {
const [moviesAvailable, setMoviesAvailable] = useState(true);
const [tvShowsAvailable, setTvShowsAvailable] = useState(true);
// Загрузка информации о категории
useEffect(() => {
async function fetchCategory() {
async function fetchCategoryName() {
try {
const response = await categoriesAPI.getCategory(categoryId);
setCategory(response.data);
setCategoryName(response.data.name);
} catch (error) {
console.error('Error fetching category:', error);
setError('Не удалось загрузить информацию о категории');
}
}
if (categoryId) {
fetchCategory();
fetchCategoryName();
}
}, [categoryId]);
// Загрузка фильмов по категории
useEffect(() => {
async function fetchMovies() {
async function fetchData() {
if (!categoryId) return;
setLoading(true);
setError(null);
try {
const response = await categoriesAPI.getMoviesByCategory(categoryId, page);
if (response.data.results) {
// Добавляем дебаг-логи
console.log(`Получены фильмы для категории ${categoryId}, страница ${page}:`, {
count: response.data.results.length,
ids: response.data.results.slice(0, 5).map(m => m.id),
titles: response.data.results.slice(0, 5).map(m => m.title)
});
// Проверяем, есть ли фильмы в этой категории
let response;
if (mediaType === 'movies') {
response = await categoriesAPI.getMoviesByCategory(categoryId, page);
const hasMovies = response.data.results.length > 0;
setMoviesAvailable(hasMovies);
// Если фильмов нет, а выбран тип "movies", пробуем переключиться на сериалы
if (!hasMovies && mediaType === 'movies' && tvShowsAvailable) {
if (page === 1) setMoviesAvailable(hasMovies);
setItems(response.data.results);
setTotalPages(response.data.total_pages);
if (!hasMovies && tvShowsAvailable && page === 1) {
setMediaType('tv');
} else {
setMovies(response.data.results);
// Устанавливаем общее количество страниц
if (response.data.total_pages) {
setTotalPages(response.data.total_pages);
}
}
} else {
setMoviesAvailable(false);
if (mediaType === 'movies' && tvShowsAvailable) {
setMediaType('tv');
} else {
setError('Не удалось загрузить фильмы');
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
const hasTvShows = response.data.results.length > 0;
if (page === 1) setTvShowsAvailable(hasTvShows);
const transformedShows = response.data.results.map((show: TVShow) => ({
...show,
title: show.name,
release_date: show.first_air_date,
}));
setItems(transformedShows);
setTotalPages(response.data.total_pages);
if (!hasTvShows && moviesAvailable && page === 1) {
setMediaType('movies');
}
}
} catch (error) {
console.error('Error fetching movies:', error);
setMoviesAvailable(false);
if (mediaType === 'movies' && tvShowsAvailable) {
setMediaType('tv');
} else {
setError('Ошибка при загрузке фильмов');
}
} catch (err) {
setError('Ошибка при загрузке данных');
console.error(err);
} finally {
setLoading(false);
}
}
if (mediaType === 'movies') {
fetchMovies();
}
}, [categoryId, mediaType, page, tvShowsAvailable]);
fetchData();
}, [categoryId, mediaType, page, moviesAvailable, tvShowsAvailable]);
// Загрузка сериалов по категории
useEffect(() => {
async function fetchTVShows() {
if (!categoryId) return;
setLoading(true);
setError(null);
try {
const response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
if (response.data.results) {
// Добавляем дебаг-логи
console.log(`Получены сериалы для категории ${categoryId}, страница ${page}:`, {
count: response.data.results.length,
ids: response.data.results.slice(0, 5).map(tv => tv.id),
names: response.data.results.slice(0, 5).map(tv => tv.name)
});
// Проверяем, есть ли сериалы в этой категории
const hasTVShows = response.data.results.length > 0;
setTvShowsAvailable(hasTVShows);
// Если сериалов нет, а выбран тип "tv", пробуем переключиться на фильмы
if (!hasTVShows && mediaType === 'tv' && moviesAvailable) {
setMediaType('movies');
} else {
setTvShows(response.data.results);
// Устанавливаем общее количество страниц
if (response.data.total_pages) {
setTotalPages(response.data.total_pages);
}
}
} else {
setTvShowsAvailable(false);
if (mediaType === 'tv' && moviesAvailable) {
setMediaType('movies');
} else {
setError('Не удалось загрузить сериалы');
}
}
} catch (error) {
console.error('Error fetching TV shows:', error);
setTvShowsAvailable(false);
if (mediaType === 'tv' && moviesAvailable) {
setMediaType('movies');
} else {
setError('Ошибка при загрузке сериалов');
}
} finally {
setLoading(false);
}
}
if (mediaType === 'tv') {
fetchTVShows();
}
}, [categoryId, mediaType, page, moviesAvailable]);
const handleMediaTypeChange = (type: MediaType) => {
if (type === 'movies' && !moviesAvailable) return;
if (type === 'tv' && !tvShowsAvailable) return;
setMediaType(type);
setPage(1);
};
function handleGoBack() {
window.history.back();
}
// Функции для пагинации
function handlePageChange(newPage: number) {
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
};
function renderPagination() {
const Pagination = useMemo(() => {
if (totalPages <= 1) return null;
const pageButtons = [];
// Отображаем максимум 5 страниц вокруг текущей
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, startPage + 4);
// Кнопка "Предыдущая"
pageButtons.push(
<PaginationButton
key="prev"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
&lt;
</PaginationButton>
);
// Отображаем первую страницу и многоточие, если startPage > 1
if (startPage > 1) {
pageButtons.push(
<PaginationButton
key="1"
onClick={() => handlePageChange(1)}
$active={page === 1}
>
1
</PaginationButton>
);
if (startPage > 2) {
pageButtons.push(
<span key="dots1" style={{ color: 'white' }}>...</span>
);
}
const maxPagesToShow = 5;
let startPage: number;
let endPage: number;
if (totalPages <= maxPagesToShow) {
startPage = 1;
endPage = totalPages;
} else {
const maxPagesBeforeCurrent = Math.floor(maxPagesToShow / 2);
const maxPagesAfterCurrent = Math.ceil(maxPagesToShow / 2) - 1;
if (page <= maxPagesBeforeCurrent) {
startPage = 1;
endPage = maxPagesToShow;
} else if (page + maxPagesAfterCurrent >= totalPages) {
startPage = totalPages - maxPagesToShow + 1;
endPage = totalPages;
} else {
startPage = page - maxPagesBeforeCurrent;
endPage = page + maxPagesAfterCurrent;
}
}
// Отображаем страницы вокруг текущей
// Previous button
pageButtons.push(
<button key="prev" onClick={() => handlePageChange(page - 1)} disabled={page === 1} className="px-3 py-1 rounded-md bg-card hover:bg-card/80 disabled:opacity-50 disabled:cursor-not-allowed">
&lt;
</button>
);
if (startPage > 1) {
pageButtons.push(<button key={1} onClick={() => handlePageChange(1)} className="px-3 py-1 rounded-md bg-card hover:bg-card/80">1</button>);
if (startPage > 2) {
pageButtons.push(<span key="dots1" className="px-3 py-1">...</span>);
}
}
for (let i = startPage; i <= endPage; i++) {
pageButtons.push(
<PaginationButton
key={i}
onClick={() => handlePageChange(i)}
$active={page === i}
>
<button key={i} onClick={() => handlePageChange(i)} disabled={page === i} className={`px-3 py-1 rounded-md ${page === i ? 'bg-accent text-white' : 'bg-card hover:bg-card/80'}`}>
{i}
</PaginationButton>
</button>
);
}
// Отображаем многоточие и последнюю страницу, если endPage < totalPages
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pageButtons.push(
<span key="dots2" style={{ color: 'white' }}>...</span>
);
pageButtons.push(<span key="dots2" className="px-3 py-1">...</span>);
}
pageButtons.push(
<PaginationButton
key={totalPages}
onClick={() => handlePageChange(totalPages)}
$active={page === totalPages}
>
{totalPages}
</PaginationButton>
);
pageButtons.push(<button key={totalPages} onClick={() => handlePageChange(totalPages)} className="px-3 py-1 rounded-md bg-card hover:bg-card/80">{totalPages}</button>);
}
// Кнопка "Следующая"
// Next button
pageButtons.push(
<PaginationButton
key="next"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
<button key="next" onClick={() => handlePageChange(page + 1)} disabled={page === totalPages} className="px-3 py-1 rounded-md bg-card hover:bg-card/80 disabled:opacity-50 disabled:cursor-not-allowed">
&gt;
</PaginationButton>
</button>
);
return <PaginationContainer>{pageButtons}</PaginationContainer>;
}
return <div className="flex justify-center items-center gap-2 my-8 text-foreground">{pageButtons}</div>;
}, [page, totalPages]);
if (error) {
return (
<Container>
<BackButton onClick={handleGoBack}>
<span></span> Назад к категориям
</BackButton>
<ErrorMessage>{error}</ErrorMessage>
</Container>
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
<button onClick={() => router.back()} className="flex items-center gap-2 mb-4 px-4 py-2 rounded-md bg-card hover:bg-card/80 text-foreground">
<ArrowLeft size={16} />
Назад к категориям
</button>
<div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
{error}
</div>
</div>
);
}
return (
<Container>
<BackButton onClick={handleGoBack}>
<span></span> Назад к категориям
</BackButton>
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
<button onClick={() => router.back()} className="flex items-center gap-2 mb-4 px-4 py-2 rounded-md bg-card hover:bg-card/80 text-foreground">
<ArrowLeft size={16} />
Назад к категориям
</button>
<Title>{category?.name || 'Загрузка...'}</Title>
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
{categoryName || 'Загрузка...'}
</h1>
<ButtonsContainer>
<TabButton
$active={mediaType === 'movies'}
onClick={() => {
if (moviesAvailable) {
setMediaType('movies');
setPage(1); // Сбрасываем страницу при переключении типа контента
}
}}
disabled={!moviesAvailable}
style={{ opacity: moviesAvailable ? 1 : 0.5, cursor: moviesAvailable ? 'pointer' : 'not-allowed' }}
<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'}`}
>
Фильмы
</TabButton>
<TabButton
$active={mediaType === 'tv'}
onClick={() => {
if (tvShowsAvailable) {
setMediaType('tv');
setPage(1); // Сбрасываем страницу при переключении типа контента
}
}}
disabled={!tvShowsAvailable}
style={{ opacity: tvShowsAvailable ? 1 : 0.5, cursor: tvShowsAvailable ? 'pointer' : 'not-allowed' }}
</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'}`}
>
Сериалы
</TabButton>
</ButtonsContainer>
</button>
</div>
{loading ? (
<LoadingContainer>
<Spinner />
</LoadingContainer>
<div className="flex justify-center items-center min-h-[40vh]">
<Loader2 className="w-12 h-12 animate-spin text-accent" />
</div>
) : (
<>
<MediaGrid>
{mediaType === 'movies' ? (
movies.length > 0 ? (
movies.map(movie => (
<MovieCard
key={`movie-${categoryId}-${movie.id}-${page}`}
movie={movie}
/>
))
) : (
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '2rem' }}>
Нет фильмов в этой категории
</div>
)
) : (
tvShows.length > 0 ? (
tvShows.map(tvShow => (
<MovieCard
key={`tv-${categoryId}-${tvShow.id}-${page}`}
movie={tvShow}
/>
))
) : (
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '2rem' }}>
Нет сериалов в этой категории
</div>
)
)}
</MediaGrid>
{items.length > 0 ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6">
{items.map(item => (
<MovieCard
key={`${mediaType}-${item.id}-${page}`}
movie={item}
/>
))}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p>Нет {mediaType === 'movies' ? 'фильмов' : 'сериалов'} в этой категории.</p>
</div>
)}
{/* Отображаем пагинацию */}
{renderPagination()}
{Pagination}
</>
)}
</Container>
</div>
);
}

View File

@@ -1,76 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import styled from 'styled-components';
import { categoriesAPI } from '@/lib/api';
import { Category } from '@/lib/api';
import CategoryCard from '@/components/CategoryCard';
// Styled Components
const Container = styled.div`
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1rem;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
`;
const Title = styled.h1`
font-size: 2rem;
font-weight: bold;
margin-bottom: 1.5rem;
color: #fff;
`;
const Subtitle = styled.p`
color: rgba(255, 255, 255, 0.7);
font-size: 1rem;
margin-top: -0.5rem;
margin-bottom: 2rem;
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
`;
const Spinner = styled.div`
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: #3182ce;
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const ErrorMessage = styled.div`
color: #fc8181;
text-align: center;
padding: 2rem;
background: rgba(252, 129, 129, 0.1);
border-radius: 0.5rem;
margin: 2rem 0;
`;
interface CategoryWithBackground extends Category {
backgroundUrl?: string;
backgroundUrl?: string | null;
}
function CategoriesPage() {
@@ -151,34 +87,44 @@ function CategoriesPage() {
if (error) {
return (
<Container>
<Title>Категории</Title>
<ErrorMessage>{error}</ErrorMessage>
</Container>
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold text-foreground mb-4">Категории</h1>
<div className="text-red-500 text-center p-4 bg-red-50 dark:bg-red-900/50 rounded-lg">
{error}
</div>
</div>
</div>
);
}
return (
<Container>
<Title>Категории</Title>
<Subtitle>Различные жанры фильмов и сериалов</Subtitle>
{loading ? (
<LoadingContainer>
<Spinner />
</LoadingContainer>
) : (
<Grid>
{categories.map((category) => (
<CategoryCard
key={category.id}
category={category}
backgroundUrl={category.backgroundUrl}
/>
))}
</Grid>
)}
</Container>
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-foreground mb-4">Категории</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Выберите категорию для просмотра фильмов
</p>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-accent"></div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{categories.map((category, index) => (
<CategoryCard
key={index}
category={category}
backgroundUrl={category.backgroundUrl}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,82 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { favoritesAPI } from '@/lib/favoritesApi';
import { getImageUrl } from '@/lib/neoApi';
const Container = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
`;
const Title = styled.h1`
font-size: 2rem;
color: white;
margin-bottom: 2rem;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
`;
const Card = styled(Link)`
position: relative;
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s;
text-decoration: none;
&:hover {
transform: translateY(-5px);
}
`;
const Poster = styled.div`
position: relative;
width: 100%;
aspect-ratio: 2/3;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
overflow: hidden;
img {
object-fit: cover;
}
`;
const Info = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
`;
const MediaTitle = styled.h2`
font-size: 1rem;
color: white;
margin: 0;
`;
const MediaType = styled.span`
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
`;
const EmptyState = styled.div`
text-align: center;
color: rgba(255, 255, 255, 0.7);
padding: 4rem 0;
`;
import { Loader2, HeartCrack } from 'lucide-react';
interface Favorite {
id: number;
@@ -104,7 +34,6 @@ export default function FavoritesPage() {
setFavorites(response.data);
} catch (error) {
console.error('Failed to fetch favorites:', error);
// If token is invalid, clear it and redirect to login
localStorage.removeItem('token');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
@@ -119,52 +48,73 @@ export default function FavoritesPage() {
if (loading) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>Загрузка...</EmptyState>
</Container>
<div className="min-h-screen bg-background flex items-center justify-center">
<Loader2 className="h-16 w-16 animate-spin text-accent" />
</div>
);
}
if (favorites.length === 0) {
return (
<Container>
<Title>Избранное</Title>
<EmptyState>
У вас пока нет избранных фильмов и сериалов
</EmptyState>
</Container>
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<HeartCrack size={80} className="mx-auto mb-6 text-gray-400" />
<h1 className="text-3xl font-bold text-foreground mb-4">Избранное пусто</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
У вас пока нет избранных фильмов и сериалов
</p>
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-accent rounded-lg hover:bg-accent/90 transition-colors"
>
Найти фильмы
</Link>
</div>
</div>
);
}
return (
<Container>
<Title>Избранное</Title>
<Grid>
{favorites.map(favorite => (
<Card
key={`${favorite.mediaType}-${favorite.mediaId}`}
href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`}
>
<Poster>
<Image
src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/images/placeholder.jpg'}
alt={favorite.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
unoptimized
/>
</Poster>
<Info>
<MediaTitle>{favorite.title}</MediaTitle>
<MediaType>{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}</MediaType>
</Info>
</Card>
))}
</Grid>
</Container>
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-foreground mb-4">Избранное</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Ваша коллекция любимых фильмов и сериалов
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{favorites.map(favorite => (
<Link
key={`${favorite.mediaType}-${favorite.mediaId}`}
href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`}
className="group"
>
<div className="overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 shadow-sm transition-all duration-300 group-hover:shadow-lg group-hover:-translate-y-1">
<div className="relative aspect-[2/3] w-full">
<Image
src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/images/placeholder.jpg'}
alt={favorite.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
className="object-cover"
unoptimized
/>
</div>
</div>
<div className="mt-3 px-1">
<h3 className="font-semibold text-base text-foreground truncate group-hover:text-accent transition-colors">
{favorite.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}
</p>
</div>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Inter } from 'next/font/google';
import './globals.css';
import { ClientLayout } from '@/components/ClientLayout';
import { Providers } from '@/components/Providers';
import type { Metadata } from 'next';
import { Analytics } from "@vercel/analytics/react";
import { TermsChecker } from './providers/terms-check';
@@ -23,7 +24,9 @@ export default function RootLayout({
<meta name="darkreader-lock" />
</head>
<body className={inter.className} suppressHydrationWarning>
<ClientLayout>{children}</ClientLayout>
<Providers>
<ClientLayout>{children}</ClientLayout>
</Providers>
<TermsChecker />
<Analytics />
</body>

View File

@@ -1,170 +1,9 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useState, useEffect } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { useRouter } from 'next/navigation';
const Container = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
`;
const Title = styled.h2`
font-size: 2rem;
font-weight: 700;
color: #fff;
text-align: center;
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-bottom: 2rem;
font-size: 1rem;
`;
const InputGroup = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
`;
const Label = styled.label`
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
`;
const Input = styled.input`
width: 100%;
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
transition: all 0.2s;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
&:focus {
outline: none;
border-color: #2196f3;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
}
`;
const Button = styled.button`
width: 100%;
background: linear-gradient(to right, #2196f3, #1e88e5);
color: white;
padding: 1rem;
border-radius: 12px;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 1rem;
&:hover {
background: linear-gradient(to right, #1e88e5, #1976d2);
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
}
&:active {
transform: translateY(0);
}
`;
const Divider = styled.div`
display: flex;
align-items: center;
text-align: center;
margin: 2rem 0;
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
`;
const DividerText = styled.span`
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
padding: 0 1rem;
`;
const GoogleButton = styled(Button)`
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
`;
const ToggleText = styled.p`
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-top: 2rem;
button {
color: #2196f3;
background: none;
border: none;
padding: 0;
margin-left: 0.5rem;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
`;
const ErrorMessage = styled.div`
color: #ff5252;
font-size: 0.875rem;
text-align: center;
padding: 0.75rem;
background: rgba(255, 82, 82, 0.1);
border-radius: 8px;
margin-top: 1rem;
`;
import Image from 'next/image';
export default function LoginClient() {
const [isLogin, setIsLogin] = useState(true);
@@ -172,10 +11,10 @@ export default function LoginClient() {
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { login, register } = useAuth();
// Redirect authenticated users away from /login
useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.replace('/');
@@ -185,114 +24,105 @@ export default function LoginClient() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
if (isLogin) {
await login(email, password);
} else {
await register(email, password, name);
// Сохраняем пароль для автовхода после верификации
localStorage.setItem('password', password);
router.push(`/verify?email=${encodeURIComponent(email)}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally {
setIsLoading(false);
}
};
const handleGoogleSignIn = () => {
signIn('google', { callbackUrl: '/' });
};
return (
<Container>
<div>
<Title>{isLogin ? 'С возвращением!' : 'Создать аккаунт'}</Title>
<Subtitle>
{isLogin
? 'Войдите в свой аккаунт для доступа к фильмам'
: 'Зарегистрируйтесь для доступа ко всем возможностям'}
</Subtitle>
</div>
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{!isLogin && (
<div>
<input
type="text"
placeholder="Имя"
value={name}
onChange={(e) => setName(e.target.value)}
required={!isLogin}
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400"
/>
</div>
)}
<Form onSubmit={handleSubmit}>
{!isLogin && (
<InputGroup>
<Label htmlFor="name">Имя</Label>
<Input
id="name"
type="text"
placeholder="Введите ваше имя"
value={name}
onChange={(e) => setName(e.target.value)}
required={!isLogin}
<div>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400"
/>
</InputGroup>
</div>
<div>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Загрузка...' : isLogin ? 'Войти' : 'Зарегистрироваться'}
</button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-warm-200 dark:border-warm-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-warm-50 dark:bg-warm-900 text-warm-500">или</span>
</div>
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-warm-200 dark:border-warm-700 rounded-lg bg-white dark:bg-warm-800 hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-900 dark:text-warm-50 transition-colors"
>
<Image src="/google.svg" alt="Google" width={20} height={20} />
Продолжить с Google
</button>
{error && (
<div className="mt-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
<InputGroup>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Введите ваш email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</InputGroup>
<InputGroup>
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
placeholder="Введите ваш пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</InputGroup>
{error && <ErrorMessage>{error}</ErrorMessage>}
<Button type="submit">
{isLogin ? 'Войти' : 'Зарегистрироваться'}
</Button>
</Form>
<Divider>
<DividerText>или</DividerText>
</Divider>
<GoogleButton type="button" onClick={handleGoogleSignIn}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"
fill="#4285f4"
/>
<path
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
fill="#34a853"
/>
<path
d="M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.707V4.961H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.046l3.007-2.339z"
fill="#fbbc05"
/>
<path
d="M9 3.582c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.961L3.964 7.3C4.672 5.173 6.656 3.582 9 3.582z"
fill="#ea4335"
/>
</svg>
Продолжить с Google
</GoogleButton>
<ToggleText>
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}
<button type="button" onClick={() => setIsLogin(!isLogin)}>
{isLogin ? 'Зарегистрироваться' : 'Войти'}
</button>
</ToggleText>
</Container>
<div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}{' '}
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="text-accent hover:underline focus:outline-none"
>
{isLogin ? 'Зарегистрироваться' : 'Войти'}
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,234 +1,14 @@
'use client';
import { useState, useEffect } from 'react';
import styled from 'styled-components';
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/api';
import { useSettings } from '@/hooks/useSettings';
import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
import { formatDate } from '@/lib/utils';
declare global {
interface Window {
kbox: any;
}
}
const Container = styled.div`
width: 100%;
min-height: 100vh;
padding: 0 24px;
`;
const Content = styled.div`
width: 100%;
max-width: 1200px;
margin: 0 auto;
@media (max-width: 768px) {
padding-top: 1rem;
}
`;
const MovieInfo = styled.div`
display: flex;
gap: 30px;
margin-bottom: 1rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 1.5rem;
}
`;
const PosterContainer = styled.div`
flex-shrink: 0;
position: relative;
@media (max-width: 768px) {
display: flex;
justify-content: center;
}
`;
const Poster = styled.img`
width: 300px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
width: 200px;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
@media (max-width: 480px) {
width: 160px;
}
`;
const Details = styled.div`
flex: 1;
@media (max-width: 768px) {
padding: 0 0.5rem;
}
`;
const Title = styled.h1`
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: white;
@media (max-width: 768px) {
font-size: 1.75rem;
margin-bottom: 0.75rem;
text-align: center;
}
@media (max-width: 480px) {
font-size: 1.5rem;
}
`;
const Info = styled.div`
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
@media (max-width: 768px) {
justify-content: center;
gap: 0.75rem;
}
`;
const InfoItem = styled.span`
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
@media (max-width: 480px) {
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.35rem 0.6rem;
border-radius: 4px;
}
`;
const GenreList = styled.div`
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
@media (max-width: 768px) {
justify-content: center;
}
`;
const Genre = styled.span`
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
@media (max-width: 480px) {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
background: rgba(59, 130, 246, 0.15);
}
`;
const Tagline = styled.div`
font-style: italic;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 1rem;
@media (max-width: 768px) {
text-align: center;
font-size: 0.9rem;
}
`;
const Overview = styled.p`
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
@media (max-width: 768px) {
font-size: 0.95rem;
text-align: justify;
margin-bottom: 1.5rem;
}
@media (max-width: 480px) {
font-size: 0.9rem;
line-height: 1.5;
}
`;
const ActionButtons = styled.div`
display: flex;
gap: 1rem;
margin-top: 1.5rem;
@media (max-width: 768px) {
justify-content: center;
margin-top: 1rem;
}
`;
const WatchButton = styled.button`
background: #e50914;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
@media (max-width: 480px) {
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
}
&:hover {
background: #f40612;
}
`;
const PlayerSection = styled.div`
margin-top: 2rem;
@media (max-width: 768px) {
margin-top: 1.5rem;
}
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
color: rgba(255, 255, 255, 0.7);
`;
const ErrorContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
color: #ff4444;
`;
import { useParams } from 'next/navigation';
import { PlayCircle, ArrowLeft } from 'lucide-react';
interface MovieContentProps {
movieId: string;
@@ -236,9 +16,11 @@ interface MovieContentProps {
}
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
const { settings } = useSettings();
const [movie] = useState<MovieDetails>(initialMovie);
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 fetchImdbId = async () => {
@@ -254,62 +36,135 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
fetchImdbId();
}, [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 (
<Container>
<Content>
<MovieInfo>
<PosterContainer>
<Poster
src={getImageUrl(movie.poster_path)}
alt={movie.title}
loading="eager"
/>
</PosterContainer>
<>
<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>
<Details>
<Title>{movie.title}</Title>
<Info>
<InfoItem>Рейтинг: {movie.vote_average.toFixed(1)}</InfoItem>
<InfoItem>Длительность: {movie.runtime} мин.</InfoItem>
<InfoItem>Дата выхода: {formatDate(movie.release_date)}</InfoItem>
</Info>
<GenreList>
{movie.genres.map(genre => (
<Genre key={genre.id}>{genre.name}</Genre>
))}
</GenreList>
{movie.tagline && <Tagline>{movie.tagline}</Tagline>}
<Overview>{movie.overview}</Overview>
<ActionButtons>
{imdbId && (
<WatchButton
onClick={() => document.getElementById('movie-player')?.scrollIntoView({ behavior: 'smooth' })}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5.14V19.14L19 12.14L8 5.14Z" fill="currentColor" />
</svg>
Смотреть
</WatchButton>
{/* 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>
)}
<FavoriteButton
mediaId={movie.id.toString()}
mediaType="movie"
title={movie.title}
posterPath={movie.poster_path}
/>
</ActionButtons>
</Details>
</MovieInfo>
{imdbId && (
<PlayerSection id="movie-player">
<MoviePlayer
imdbId={imdbId}
/>
</PlayerSection>
)}
</Content>
</Container>
<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"
/>
</div>
{/* Desktop-only Embedded Player */}
{imdbId && (
<div id="movie-player" className="mt-10 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>
)}
</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>
)}
</>
);
}

View File

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

View File

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

View File

@@ -1,184 +1,87 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { HeartIcon } from '@/components/Icons/HeartIcon';
import MovieCard from '@/components/MovieCard';
import { useMovies } from '@/hooks/useMovies';
import { useMovies, MovieCategory } from '@/hooks/useMovies';
import MovieTile from '@/components/MovieTile';
import Pagination from '@/components/Pagination';
import { getImageUrl } from '@/lib/neoApi';
import FavoriteButton from '@/components/FavoriteButton';
const Container = styled.div`
min-height: 100vh;
width: 100%;
padding: 24px;
padding-top: 84px;
@media (min-width: 769px) {
padding-left: 264px;
}
`;
const FeaturedMovie = styled.div<{ $backdrop: string }>`
position: relative;
width: 100%;
height: 600px;
background-image: ${props => `url(${props.$backdrop})`};
background-size: cover;
background-position: center;
margin-bottom: 2rem;
border-radius: 24px;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.8) 100%);
}
`;
const Overlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 2rem;
`;
const FeaturedContent = styled.div`
position: relative;
z-index: 1;
max-width: 800px;
padding: 2rem;
color: white;
`;
const GenreTags = styled.div`
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
`;
const GenreTag = styled.span`
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
`;
const Title = styled.h1`
font-size: 3rem;
font-weight: bold;
margin-bottom: 1rem;
`;
const Overview = styled.p`
font-size: 1.125rem;
margin-bottom: 2rem;
opacity: 0.9;
line-height: 1.6;
`;
const ButtonContainer = styled.div`
display: flex;
gap: 1rem;
`;
const WatchButton = styled.button`
padding: 0.75rem 2rem;
background: #e50914;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f40612;
}
`;
const MoviesGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 2rem;
margin-top: 2rem;
`;
import HorizontalSlider from '@/components/HorizontalSlider';
export default function HomePage() {
const { movies, featuredMovie, loading, error, totalPages, currentPage, setPage } = useMovies(1);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
if (loading && !movies.length) {
return (
<Container>
<div>Загрузка...</div>
</Container>
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
Загрузка...
</div>
);
}
if (error) {
return (
<Container>
<div>{error}</div>
</Container>
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-red-500">
{error}
</div>
);
}
const filteredMovies = selectedGenre
? movies.filter(movie => movie.genre_ids.includes(parseInt(selectedGenre)))
: movies;
const sliderMovies = movies.slice(0, 10);
return (
<Container>
{featuredMovie && (
<FeaturedMovie $backdrop={getImageUrl(featuredMovie.backdrop_path, 'original')}>
<Overlay>
<FeaturedContent>
<GenreTags>
{featuredMovie.genres?.map(genre => (
<GenreTag key={genre.id}>{genre.name}</GenreTag>
))}
</GenreTags>
<Title>{featuredMovie.title}</Title>
<Overview>{featuredMovie.overview}</Overview>
<ButtonContainer>
<Link href={`/movie/${featuredMovie.id}`}>
<WatchButton>Смотреть</WatchButton>
</Link>
<FavoriteButton
mediaId={featuredMovie.id.toString()}
mediaType="movie"
title={featuredMovie.title}
posterPath={featuredMovie.poster_path}
/>
</ButtonContainer>
</FeaturedContent>
</Overlay>
</FeaturedMovie>
)}
<main className="min-h-screen bg-background px-4 py-6 text-foreground md:px-6 lg:px-8">
<div className="container mx-auto">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
onClick={() => setActiveTab('popular')}
className={`${
activeTab === 'popular'
? '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
onClick={() => setActiveTab('now_playing')}
className={`${
activeTab === 'now_playing'
? '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
onClick={() => setActiveTab('top_rated')}
className={`${
activeTab === 'top_rated'
? '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>
</nav>
</div>
<MoviesGrid>
{filteredMovies.map(movie => (
<MovieCard key={movie.id} movie={movie} />
))}
</MoviesGrid>
<div className="mt-6">
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{movies.map((movie) => (
<MovieTile key={movie.id} movie={movie} />
))}
</div>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
</Container>
<div className="mt-8 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</main>
);
}

View File

@@ -1,73 +1,9 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
import styled from 'styled-components';
import GlassCard from '@/components/GlassCard';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
const Container = styled.div`
min-height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-top: 80px;
background-color: #0a0a0a;
`;
const Content = styled.div`
width: 100%;
max-width: 600px;
padding: 2rem;
`;
const ProfileHeader = styled.div`
text-align: center;
margin-bottom: 2rem;
`;
const Avatar = styled.div`
width: 120px;
height: 120px;
border-radius: 50%;
background: #2196f3;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 3rem;
font-weight: bold;
margin: 0 auto 1rem;
border: 4px solid #fff;
`;
const Name = styled.h1`
color: #fff;
font-size: 2rem;
margin: 0;
`;
const Email = styled.p`
color: rgba(255, 255, 255, 0.7);
margin: 0.5rem 0 0;
`;
const SignOutButton = styled.button`
background: #ff4444;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
margin-top: 1rem;
&:hover {
background: #ff2020;
}
`;
import { Loader2, LogOut } from 'lucide-react';
export default function ProfilePage() {
const { logout } = useAuth();
@@ -93,34 +29,38 @@ export default function ProfilePage() {
if (loading) {
return (
<Container>
<Content>
<GlassCard>
<div>Загрузка...</div>
</GlassCard>
</Content>
</Container>
<div className="flex min-h-screen w-full items-center justify-center bg-[#F9F6EE] dark:bg-gray-900">
<Loader2 className="h-16 w-16 animate-spin text-red-500" />
</div>
);
}
if (!userName) {
// This can happen briefly before redirect, or if localStorage is cleared.
return null;
}
return (
<Container>
<Content>
<GlassCard>
<ProfileHeader>
<Avatar>
<div className="min-h-screen w-full bg-[#F9F6EE] dark:bg-[#1e1e1e] pt-24 sm:pt-32">
<div className="flex justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-[#49372E] p-8 shadow-lg">
<div className="flex flex-col items-center text-center">
<div className="mb-6 flex h-28 w-28 items-center justify-center rounded-full bg-gray-200 dark:bg-white/10 text-4xl font-bold text-gray-700 dark:text-gray-200 ring-4 ring-gray-100 dark:ring-white/5">
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</Avatar>
<Name>{userName}</Name>
<Email>{userEmail}</Email>
<SignOutButton onClick={handleSignOut}>Выйти</SignOutButton>
</ProfileHeader>
</GlassCard>
</Content>
</Container>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{userName}</h1>
<p className="mt-2 text-base text-gray-500 dark:text-gray-300">{userEmail}</p>
<button
onClick={handleSignOut}
className="mt-8 inline-flex items-center gap-2.5 rounded-lg bg-red-600 px-6 py-3 text-base font-semibold text-white shadow-md transition-colors hover:bg-red-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
<LogOut size={20} />
<span>Выйти</span>
</button>
</div>
</div>
</div>
</div>
);
}

61
src/app/search/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
'use client';
import { useState, useEffect, FormEvent } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api';
import MovieCard from '@/components/MovieCard';
import { Search } from 'lucide-react';
export default function SearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const currentQuery = searchParams.get('q');
if (currentQuery) {
setLoading(true);
Promise.all([
moviesAPI.searchMovies(currentQuery),
tvAPI.searchShows(currentQuery)
]).then(([movieResults, tvResults]) => {
const combined = [...(movieResults.data.results || []), ...(tvResults.data.results || [])];
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
}).catch(error => {
console.error('Search failed:', error);
setResults([]);
}).finally(() => setLoading(false));
} else {
setResults([]);
}
}, [searchParams]);
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<div className="min-h-screen bg-background text-foreground w-full px- sm:px lg:px-8 py-8">
{searchParams.get('q') && (
<h1 className="text-2xl font-bold mb-8">
Результаты поиска для: <span className="text-primary">&quot;{searchParams.get('q')}&quot;</span>
</h1>
)}
{loading ? (
<div className="text-center">Загрузка...</div>
) : results.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{results.map(item => (
<MovieCard key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`} movie={item} />
))}
</div>
) : (
searchParams.get('q') && <div className="text-center">Ничего не найдено.</div>
)}
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { tvAPI } from '@/lib/api';
import { getImageUrl } from '@/lib/neoApi';
import type { TVShowDetails } from '@/lib/api';
import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
import { formatDate } from '@/lib/utils';
import { PlayCircle, ArrowLeft } from 'lucide-react';
interface TVContentProps {
showId: string;
initialShow: TVShowDetails;
}
export default function TVContent({ showId, initialShow }: TVContentProps) {
const [show] = useState<TVShowDetails>(initialShow);
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 fetchImdbId = async () => {
try {
// Используем dedicated эндпоинт для получения IMDb ID
const { data } = await tvAPI.getImdbId(showId);
if (data?.imdb_id) {
setImdbId(data.imdb_id);
}
} catch (err) {
console.error('Error fetching IMDb ID:', err);
}
};
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
if (initialShow.external_ids?.imdb_id) {
setImdbId(initialShow.external_ids.imdb_id);
} else {
fetchImdbId();
}
}, [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 (
<>
<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"
/>
</div>
{imdbId && (
<div id="movie-player" className="mt-10 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>
)}
</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>
)}
</>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import PageLayout from '@/components/PageLayout';
import TVContent from '@/app/tv/[id]/TVContent';
import type { TVShowDetails } from '@/lib/api';
interface TVPageProps {
showId: string;
show: TVShowDetails | null;
}
export default function TVPage({ showId, show }: TVPageProps) {
if (!show) {
return (
<PageLayout>
<div className="w-full min-h-screen">
<div>Сериал не найден</div>
</div>
</PageLayout>
);
}
return (
<PageLayout>
<div className="w-full">
<TVContent showId={showId} initialShow={show} />
</div>
</PageLayout>
);
}

View File

@@ -1,53 +1,46 @@
import { Metadata } from 'next';
import TVShowPage from './TVShowPage';
import { tvAPI } from '@/lib/api';
import TVPage from '@/app/tv/[id]/TVPage';
export const dynamic = 'force-dynamic';
import { tvShowsAPI } from '@/lib/neoApi';
interface PageProps {
params: {
id: string;
};
searchParams: { [key: string]: string | string[] | undefined };
}
// Generate SEO metadata
export async function generateMetadata(
props: { params: { id: string } }
): Promise<Metadata> {
// Генерация метаданных для страницы
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
const { params } = await props;
try {
const showId = props.params.id;
const { data: show } = await tvShowsAPI.getTVShow(showId);
const showId = params.id;
const { data: show } = await tvAPI.getShow(showId);
return {
title: `${show.name} - NeoMovies`,
description: show.overview,
};
} catch (error) {
console.error('Error generating TV metadata', error);
console.error('Error generating metadata:', error);
return {
title: 'Сериал - NeoMovies',
};
}
}
// Получение данных для страницы
async function getData(id: string) {
try {
const response = await tvShowsAPI.getTVShow(id).then(res => res.data);
return { id, show: response };
const { data: show } = await tvAPI.getShow(id);
return { id, show };
} catch (error) {
console.error('Error fetching show:', error);
return { id, show: null };
throw new Error('Failed to fetch TV show');
}
}
export default async function Page(props: PageProps) {
// В Next.js 14 нужно сначала использовать параметры в асинхронной функции
try {
const tvShowId = props.params.id;
const data = await getData(tvShowId);
return <TVShowPage tvShowId={data.id} show={data.show} />;
} catch (error) {
console.error('Error loading TV show page:', error);
return <div>Ошибка загрузки страницы сериала</div>;
}
export default async function Page({ params }: PageProps) {
const { id } = params;
const data = await getData(id);
return <TVPage showId={data.id} show={data.show} />;
}

View File

@@ -1,142 +1,54 @@
'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { authAPI } from '@/lib/authApi';
import { useRouter, useSearchParams } from 'next/navigation';
import { authAPI } from '../../lib/authApi';
const Container = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
text-align: center;
`;
const Title = styled.h2`
font-size: 1.5rem;
font-weight: 600;
color: #fff;
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
margin-bottom: 2rem;
`;
const CodeInput = styled.input`
width: 100%;
padding: 1rem;
font-size: 2rem;
letter-spacing: 0.5rem;
text-align: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #fff;
transition: all 0.2s;
&:focus {
outline: none;
border-color: #2196f3;
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
}
&::placeholder {
letter-spacing: normal;
color: rgba(255, 255, 255, 0.3);
}
`;
const VerifyButton = styled.button`
width: 100%;
background: linear-gradient(to right, #2196f3, #1e88e5);
color: white;
padding: 1rem;
border-radius: 12px;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: linear-gradient(to right, #1e88e5, #1976d2);
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
`;
const ResendButton = styled.button`
background: none;
border: none;
color: #2196f3;
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ErrorMessage = styled.div`
color: #f44336;
font-size: 0.875rem;
margin-top: 0.5rem;
`;
export function VerificationClient({ email }: { email: string }) {
export default function VerificationClient() {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [countdown, setCountdown] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [isResending, setIsResending] = useState(false);
const router = useRouter();
const { verifyCode, login } = useAuth();
const searchParams = useSearchParams();
const email = searchParams.get('email');
const { verifyCode, login } = useAuth();
useEffect(() => {
if (!email) {
router.replace('/login');
return;
}
let timer: NodeJS.Timeout;
if (countdown > 0) {
timer = setInterval(() => {
setCountdown((prev) => prev - 1);
}, 1000);
}
return () => {
if (timer) clearInterval(timer);
};
}, [countdown]);
}, [countdown, email, router]);
const handleVerify = async () => {
if (code.length !== 6) {
setError('Код должен состоять из 6 цифр');
return;
}
setIsLoading(true);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const password = localStorage.getItem('password');
if (!password || !email) {
throw new Error('Не удалось получить данные для входа');
}
await verifyCode(code);
await login(email, password);
localStorage.removeItem('password');
router.replace('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally {
@@ -144,54 +56,71 @@ export function VerificationClient({ email }: { email: string }) {
}
};
const handleResend = async () => {
const handleResendCode = async () => {
if (countdown > 0 || !email) return;
setError('');
setIsResending(true);
try {
await authAPI.resendCode(email);
setCountdown(60);
} catch (err) {
setError('Не удалось отправить код');
setError(err instanceof Error ? err.message : 'Не удалось отправить код');
} finally {
setIsResending(false);
}
};
return (
<Container>
<div>
<Title>Подтвердите ваш email</Title>
<Subtitle>Мы отправили код подтверждения на {email}</Subtitle>
</div>
<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>
<p className="text-muted-foreground text-center mb-8">
Мы отправили код подтверждения на {email}
</p>
<div>
<CodeInput
type="text"
maxLength={6}
value={code}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
setCode(value);
setError('');
}}
placeholder="Введите код"
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<input
type="text"
placeholder="Введите код"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400 text-center text-lg tracking-wider"
/>
</div>
<div>
<VerifyButton
onClick={handleVerify}
<button
type="submit"
disabled={isLoading || code.length !== 6}
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 ? 'Проверка...' : 'Подтвердить'}
</VerifyButton>
</button>
</form>
<ResendButton
onClick={handleResend}
disabled={countdown > 0 || isLoading}
{error && (
<div className="mt-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
<div className="mt-6 text-center">
<button
type="button"
onClick={handleResendCode}
disabled={countdown > 0 || isResending}
className="text-accent hover:underline focus:outline-none disabled:opacity-50 disabled:no-underline text-sm"
>
{countdown > 0
? `Отправить код повторно (${countdown}с)`
{isResending
? 'Отправка...'
: countdown > 0
? `Отправить код повторно через ${countdown} сек`
: 'Отправить код повторно'}
</ResendButton>
</button>
</div>
</Container>
</div>
);
}

View File

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