mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 01:48:50 +05:00
full change ui and small fixes
This commit is contained in:
@@ -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}
|
||||
>
|
||||
<
|
||||
</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">
|
||||
<
|
||||
</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">
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
225
src/app/page.tsx
225
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
61
src/app/search/page.tsx
Normal 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">"{searchParams.get('q')}"</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>
|
||||
);
|
||||
}
|
||||
173
src/app/tv/[id]/TVContent.tsx
Normal file
173
src/app/tv/[id]/TVContent.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/app/tv/[id]/TVPage.tsx
Normal file
30
src/app/tv/[id]/TVPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user