full change ui and small fixes

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

View File

@@ -1,22 +0,0 @@
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
const isAuthPage =
request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/verify');
// Если пользователь авторизован и пытается зайти на страницу авторизации
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
// Указываем, для каких путей должен срабатывать middleware
export const config = {
matcher: ['/login', '/verify']
};

9281
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"@tabler/icons-react": "^3.26.0", "@tabler/icons-react": "^3.26.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/classnames": "^2.3.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.13",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
@@ -20,6 +21,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"classnames": "^2.5.1",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -27,13 +29,15 @@
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"next": "15.1.2", "next": "15.1.2",
"next-themes": "^0.4.6",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"resend": "^4.0.1", "resend": "^4.0.1",
"styled-components": "^6.1.13" "styled-components": "^6.1.13",
"tailwind": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@@ -1,155 +1,21 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import styled from 'styled-components'; import { categoriesAPI, Movie, TVShow } from '@/lib/api';
import { categoriesAPI } from '@/lib/api';
import MovieCard from '@/components/MovieCard'; import MovieCard from '@/components/MovieCard';
import { Movie, Category } from '@/lib/api'; import { ArrowLeft, Loader2 } from 'lucide-react';
// 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;
`;
type MediaType = 'movies' | 'tv'; type MediaType = 'movies' | 'tv';
function CategoryPage() { function CategoryPage() {
// Используем хук useParams вместо props
const params = useParams(); const params = useParams();
const router = useRouter();
const categoryId = parseInt(params.id as string); 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 [mediaType, setMediaType] = useState<MediaType>('movies');
const [movies, setMovies] = useState<Movie[]>([]); const [items, setItems] = useState<Movie[]>([]);
const [tvShows, setTvShows] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -157,328 +23,211 @@ function CategoryPage() {
const [moviesAvailable, setMoviesAvailable] = useState(true); const [moviesAvailable, setMoviesAvailable] = useState(true);
const [tvShowsAvailable, setTvShowsAvailable] = useState(true); const [tvShowsAvailable, setTvShowsAvailable] = useState(true);
// Загрузка информации о категории
useEffect(() => { useEffect(() => {
async function fetchCategory() { async function fetchCategoryName() {
try { try {
const response = await categoriesAPI.getCategory(categoryId); const response = await categoriesAPI.getCategory(categoryId);
setCategory(response.data); setCategoryName(response.data.name);
} catch (error) { } catch (error) {
console.error('Error fetching category:', error); console.error('Error fetching category:', error);
setError('Не удалось загрузить информацию о категории'); setError('Не удалось загрузить информацию о категории');
} }
} }
if (categoryId) { if (categoryId) {
fetchCategory(); fetchCategoryName();
} }
}, [categoryId]); }, [categoryId]);
// Загрузка фильмов по категории
useEffect(() => { useEffect(() => {
async function fetchMovies() { async function fetchData() {
if (!categoryId) return; if (!categoryId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await categoriesAPI.getMoviesByCategory(categoryId, page); let response;
if (mediaType === 'movies') {
if (response.data.results) { response = await categoriesAPI.getMoviesByCategory(categoryId, page);
// Добавляем дебаг-логи
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)
});
// Проверяем, есть ли фильмы в этой категории
const hasMovies = response.data.results.length > 0; const hasMovies = response.data.results.length > 0;
setMoviesAvailable(hasMovies); if (page === 1) setMoviesAvailable(hasMovies);
setItems(response.data.results);
// Если фильмов нет, а выбран тип "movies", пробуем переключиться на сериалы setTotalPages(response.data.total_pages);
if (!hasMovies && mediaType === 'movies' && tvShowsAvailable) { if (!hasMovies && tvShowsAvailable && page === 1) {
setMediaType('tv'); setMediaType('tv');
} else {
setMovies(response.data.results);
// Устанавливаем общее количество страниц
if (response.data.total_pages) {
setTotalPages(response.data.total_pages);
}
} }
} else { } else {
setMoviesAvailable(false); response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
if (mediaType === 'movies' && tvShowsAvailable) { const hasTvShows = response.data.results.length > 0;
setMediaType('tv'); if (page === 1) setTvShowsAvailable(hasTvShows);
} else { const transformedShows = response.data.results.map((show: TVShow) => ({
setError('Не удалось загрузить фильмы'); ...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) { } catch (err) {
console.error('Error fetching movies:', error); setError('Ошибка при загрузке данных');
setMoviesAvailable(false); console.error(err);
if (mediaType === 'movies' && tvShowsAvailable) {
setMediaType('tv');
} else {
setError('Ошибка при загрузке фильмов');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
fetchData();
}, [categoryId, mediaType, page, moviesAvailable, tvShowsAvailable]);
if (mediaType === 'movies') { const handleMediaTypeChange = (type: MediaType) => {
fetchMovies(); if (type === 'movies' && !moviesAvailable) return;
} if (type === 'tv' && !tvShowsAvailable) return;
}, [categoryId, mediaType, page, tvShowsAvailable]); setMediaType(type);
setPage(1);
};
// Загрузка сериалов по категории const handlePageChange = (newPage: number) => {
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]);
function handleGoBack() {
window.history.back();
}
// Функции для пагинации
function handlePageChange(newPage: number) {
if (newPage >= 1 && newPage <= totalPages) { if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage); setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }
} };
function renderPagination() { const Pagination = useMemo(() => {
if (totalPages <= 1) return null; if (totalPages <= 1) return null;
const pageButtons = []; const pageButtons = [];
// Отображаем максимум 5 страниц вокруг текущей const maxPagesToShow = 5;
const startPage = Math.max(1, page - 2); let startPage: number;
const endPage = Math.min(totalPages, startPage + 4); let endPage: number;
// Кнопка "Предыдущая" if (totalPages <= maxPagesToShow) {
pageButtons.push( startPage = 1;
<PaginationButton endPage = totalPages;
key="prev" } else {
onClick={() => handlePageChange(page - 1)} const maxPagesBeforeCurrent = Math.floor(maxPagesToShow / 2);
disabled={page === 1} const maxPagesAfterCurrent = Math.ceil(maxPagesToShow / 2) - 1;
> if (page <= maxPagesBeforeCurrent) {
&lt; startPage = 1;
</PaginationButton> endPage = maxPagesToShow;
); } else if (page + maxPagesAfterCurrent >= totalPages) {
startPage = totalPages - maxPagesToShow + 1;
// Отображаем первую страницу и многоточие, если startPage > 1 endPage = totalPages;
if (startPage > 1) { } else {
pageButtons.push( startPage = page - maxPagesBeforeCurrent;
<PaginationButton endPage = page + maxPagesAfterCurrent;
key="1" }
onClick={() => handlePageChange(1)} }
$active={page === 1}
> // Previous button
1 pageButtons.push(
</PaginationButton> <button key="prev" onClick={() => handlePageChange(page - 1)} disabled={page === 1} className="px-3 py-1 rounded-md bg-card hover:bg-card/80 disabled:opacity-50 disabled:cursor-not-allowed">
); &lt;
</button>
if (startPage > 2) { );
pageButtons.push(
<span key="dots1" style={{ color: 'white' }}>...</span> 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++) { for (let i = startPage; i <= endPage; i++) {
pageButtons.push( pageButtons.push(
<PaginationButton <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'}`}>
key={i}
onClick={() => handlePageChange(i)}
$active={page === i}
>
{i} {i}
</PaginationButton> </button>
); );
} }
// Отображаем многоточие и последнюю страницу, если endPage < totalPages
if (endPage < totalPages) { if (endPage < totalPages) {
if (endPage < totalPages - 1) { if (endPage < totalPages - 1) {
pageButtons.push( pageButtons.push(<span key="dots2" className="px-3 py-1">...</span>);
<span key="dots2" style={{ color: 'white' }}>...</span>
);
} }
pageButtons.push(<button key={totalPages} onClick={() => handlePageChange(totalPages)} className="px-3 py-1 rounded-md bg-card hover:bg-card/80">{totalPages}</button>);
pageButtons.push(
<PaginationButton
key={totalPages}
onClick={() => handlePageChange(totalPages)}
$active={page === totalPages}
>
{totalPages}
</PaginationButton>
);
} }
// Кнопка "Следующая" // Next button
pageButtons.push( pageButtons.push(
<PaginationButton <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">
key="next"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
&gt; &gt;
</PaginationButton> </button>
); );
return <PaginationContainer>{pageButtons}</PaginationContainer>; return <div className="flex justify-center items-center gap-2 my-8 text-foreground">{pageButtons}</div>;
} }, [page, totalPages]);
if (error) { if (error) {
return ( return (
<Container> <div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
<BackButton onClick={handleGoBack}> <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">
<span></span> Назад к категориям <ArrowLeft size={16} />
</BackButton> Назад к категориям
<ErrorMessage>{error}</ErrorMessage> </button>
</Container> <div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
{error}
</div>
</div>
); );
} }
return ( return (
<Container> <div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
<BackButton onClick={handleGoBack}> <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">
<span></span> Назад к категориям <ArrowLeft size={16} />
</BackButton> Назад к категориям
</button>
<Title>{category?.name || 'Загрузка...'}</Title> <h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
{categoryName || 'Загрузка...'}
</h1>
<ButtonsContainer> <div className="flex flex-col sm:flex-row gap-4 mb-8">
<TabButton <button
$active={mediaType === 'movies'} onClick={() => handleMediaTypeChange('movies')}
onClick={() => { disabled={!moviesAvailable || mediaType === 'movies'}
if (moviesAvailable) { 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'}`}
setMediaType('movies');
setPage(1); // Сбрасываем страницу при переключении типа контента
}
}}
disabled={!moviesAvailable}
style={{ opacity: moviesAvailable ? 1 : 0.5, cursor: moviesAvailable ? 'pointer' : 'not-allowed' }}
> >
Фильмы Фильмы
</TabButton> </button>
<TabButton <button
$active={mediaType === 'tv'} onClick={() => handleMediaTypeChange('tv')}
onClick={() => { disabled={!tvShowsAvailable || mediaType === 'tv'}
if (tvShowsAvailable) { 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'}`}
setMediaType('tv');
setPage(1); // Сбрасываем страницу при переключении типа контента
}
}}
disabled={!tvShowsAvailable}
style={{ opacity: tvShowsAvailable ? 1 : 0.5, cursor: tvShowsAvailable ? 'pointer' : 'not-allowed' }}
> >
Сериалы Сериалы
</TabButton> </button>
</ButtonsContainer> </div>
{loading ? ( {loading ? (
<LoadingContainer> <div className="flex justify-center items-center min-h-[40vh]">
<Spinner /> <Loader2 className="w-12 h-12 animate-spin text-accent" />
</LoadingContainer> </div>
) : ( ) : (
<> <>
<MediaGrid> {items.length > 0 ? (
{mediaType === 'movies' ? ( <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">
movies.length > 0 ? ( {items.map(item => (
movies.map(movie => ( <MovieCard
<MovieCard key={`${mediaType}-${item.id}-${page}`}
key={`movie-${categoryId}-${movie.id}-${page}`} movie={item}
movie={movie} />
/> ))}
)) </div>
) : ( ) : (
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '2rem' }}> <div className="text-center py-16 text-muted-foreground">
Нет фильмов в этой категории <p>Нет {mediaType === 'movies' ? 'фильмов' : 'сериалов'} в этой категории.</p>
</div> </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>
{/* Отображаем пагинацию */} {Pagination}
{renderPagination()}
</> </>
)} )}
</Container> </div>
); );
} }

View File

@@ -1,76 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import styled from 'styled-components';
import { categoriesAPI } from '@/lib/api'; import { categoriesAPI } from '@/lib/api';
import { Category } from '@/lib/api'; import { Category } from '@/lib/api';
import CategoryCard from '@/components/CategoryCard'; 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 { interface CategoryWithBackground extends Category {
backgroundUrl?: string; backgroundUrl?: string | null;
} }
function CategoriesPage() { function CategoriesPage() {
@@ -151,34 +87,44 @@ function CategoriesPage() {
if (error) { if (error) {
return ( return (
<Container> <div className="min-h-screen bg-background">
<Title>Категории</Title> <div className="container mx-auto px-4 py-8">
<ErrorMessage>{error}</ErrorMessage> <h1 className="text-4xl font-bold text-foreground mb-4">Категории</h1>
</Container> <div className="text-red-500 text-center p-4 bg-red-50 dark:bg-red-900/50 rounded-lg">
{error}
</div>
</div>
</div>
); );
} }
return ( return (
<Container> <div className="min-h-screen bg-background">
<Title>Категории</Title> <div className="container mx-auto px-4 py-8">
<Subtitle>Различные жанры фильмов и сериалов</Subtitle> <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 ? ( {loading ? (
<LoadingContainer> <div className="flex items-center justify-center min-h-[60vh]">
<Spinner /> <div className="animate-spin rounded-full h-16 w-16 border-b-2 border-accent"></div>
</LoadingContainer> </div>
) : ( ) : (
<Grid> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{categories.map((category) => ( {categories.map((category, index) => (
<CategoryCard <CategoryCard
key={category.id} key={index}
category={category} category={category}
backgroundUrl={category.backgroundUrl} backgroundUrl={category.backgroundUrl}
/> />
))} ))}
</Grid> </div>
)} )}
</Container> </div>
</div>
); );
} }

View File

@@ -1,82 +1,12 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { favoritesAPI } from '@/lib/favoritesApi'; import { favoritesAPI } from '@/lib/favoritesApi';
import { getImageUrl } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi';
import { Loader2, HeartCrack } from 'lucide-react';
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;
`;
interface Favorite { interface Favorite {
id: number; id: number;
@@ -104,7 +34,6 @@ export default function FavoritesPage() {
setFavorites(response.data); setFavorites(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch favorites:', error); console.error('Failed to fetch favorites:', error);
// If token is invalid, clear it and redirect to login
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('userName'); localStorage.removeItem('userName');
localStorage.removeItem('userEmail'); localStorage.removeItem('userEmail');
@@ -119,52 +48,73 @@ export default function FavoritesPage() {
if (loading) { if (loading) {
return ( return (
<Container> <div className="min-h-screen bg-background flex items-center justify-center">
<Title>Избранное</Title> <Loader2 className="h-16 w-16 animate-spin text-accent" />
<EmptyState>Загрузка...</EmptyState> </div>
</Container>
); );
} }
if (favorites.length === 0) { if (favorites.length === 0) {
return ( return (
<Container> <div className="min-h-screen bg-background flex items-center justify-center">
<Title>Избранное</Title> <div className="text-center">
<EmptyState> <HeartCrack size={80} className="mx-auto mb-6 text-gray-400" />
У вас пока нет избранных фильмов и сериалов <h1 className="text-3xl font-bold text-foreground mb-4">Избранное пусто</h1>
</EmptyState> <p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
</Container> У вас пока нет избранных фильмов и сериалов
</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 ( return (
<Container> <div className="min-h-screen bg-background">
<Title>Избранное</Title> <div className="container mx-auto px-4 py-8">
<Grid> <div className="mb-8">
{favorites.map(favorite => ( <h1 className="text-4xl font-bold text-foreground mb-4">Избранное</h1>
<Card <p className="text-lg text-gray-600 dark:text-gray-400">
key={`${favorite.mediaType}-${favorite.mediaId}`} Ваша коллекция любимых фильмов и сериалов
href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`} </p>
> </div>
<Poster>
<Image <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/images/placeholder.jpg'} {favorites.map(favorite => (
alt={favorite.title} <Link
fill key={`${favorite.mediaType}-${favorite.mediaId}`}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`}
className="object-cover" className="group"
unoptimized >
/> <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">
</Poster> <div className="relative aspect-[2/3] w-full">
<Info> <Image
<MediaTitle>{favorite.title}</MediaTitle> src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/images/placeholder.jpg'}
<MediaType>{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}</MediaType> alt={favorite.title}
</Info> fill
</Card> sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
))} className="object-cover"
</Grid> unoptimized
</Container> />
</div>
</div>
<div className="mt-3 px-1">
<h3 className="font-semibold text-base text-foreground truncate group-hover:text-accent transition-colors">
{favorite.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}
</p>
</div>
</Link>
))}
</div>
</div>
</div>
); );
} }

View File

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

View File

@@ -1,170 +1,9 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Image from 'next/image';
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;
`;
export default function LoginClient() { export default function LoginClient() {
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
@@ -172,10 +11,10 @@ export default function LoginClient() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const { login, register } = useAuth(); const { login, register } = useAuth();
// Redirect authenticated users away from /login
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && localStorage.getItem('token')) { if (typeof window !== 'undefined' && localStorage.getItem('token')) {
router.replace('/'); router.replace('/');
@@ -185,114 +24,105 @@ export default function LoginClient() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setIsLoading(true);
try { try {
if (isLogin) { if (isLogin) {
await login(email, password); await login(email, password);
} else { } else {
await register(email, password, name); await register(email, password, name);
// Сохраняем пароль для автовхода после верификации
localStorage.setItem('password', password); localStorage.setItem('password', password);
router.push(`/verify?email=${encodeURIComponent(email)}`); router.push(`/verify?email=${encodeURIComponent(email)}`);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка'); setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally {
setIsLoading(false);
} }
}; };
const handleGoogleSignIn = () => {
signIn('google', { callbackUrl: '/' });
};
return ( return (
<Container> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div> <div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
<Title>{isLogin ? 'С возвращением!' : 'Создать аккаунт'}</Title> <form onSubmit={handleSubmit} className="space-y-6">
<Subtitle> {!isLogin && (
{isLogin <div>
? 'Войдите в свой аккаунт для доступа к фильмам' <input
: 'Зарегистрируйтесь для доступа ко всем возможностям'} type="text"
</Subtitle> placeholder="Имя"
</div> 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}> <div>
{!isLogin && ( <input
<InputGroup> type="email"
<Label htmlFor="name">Имя</Label> placeholder="Email"
<Input value={email}
id="name" onChange={(e) => setEmail(e.target.value)}
type="text" required
placeholder="Введите ваше имя" 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"
value={name}
onChange={(e) => setName(e.target.value)}
required={!isLogin}
/> />
</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> <div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
<Label htmlFor="email">Email</Label> {isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}{' '}
<Input <button
id="email" type="button"
type="email" onClick={() => setIsLogin(!isLogin)}
placeholder="Введите ваш email" className="text-accent hover:underline focus:outline-none"
value={email} >
onChange={(e) => setEmail(e.target.value)} {isLogin ? 'Зарегистрироваться' : 'Войти'}
required </button>
/> </div>
</InputGroup> </div>
</div>
<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>
); );
} }

View File

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

View File

@@ -1,234 +1,14 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import styled from 'styled-components'; import Image from 'next/image';
import { moviesAPI } from '@/lib/neoApi'; import { moviesAPI } from '@/lib/neoApi';
import { getImageUrl } from '@/lib/neoApi'; import { getImageUrl } from '@/lib/neoApi';
import type { MovieDetails } from '@/lib/api'; import type { MovieDetails } from '@/lib/api';
import { useSettings } from '@/hooks/useSettings';
import MoviePlayer from '@/components/MoviePlayer'; import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton'; import FavoriteButton from '@/components/FavoriteButton';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
import { PlayCircle, ArrowLeft } from 'lucide-react';
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';
interface MovieContentProps { interface MovieContentProps {
movieId: string; movieId: string;
@@ -236,9 +16,11 @@ interface MovieContentProps {
} }
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) { export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
const { settings } = useSettings();
const [movie] = useState<MovieDetails>(initialMovie); const [movie] = useState<MovieDetails>(initialMovie);
const [imdbId, setImdbId] = useState<string | null>(null); const [imdbId, setImdbId] = useState<string | null>(null);
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
const [isControlsVisible, setIsControlsVisible] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const fetchImdbId = async () => { const fetchImdbId = async () => {
@@ -254,62 +36,135 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
fetchImdbId(); fetchImdbId();
}, [movieId]); }, [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 ( return (
<Container> <>
<Content> <div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
<MovieInfo> <div className="w-full">
<PosterContainer> <div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<Poster {/* Left Column: Poster */}
src={getImageUrl(movie.poster_path)} <div className="md:col-span-1">
alt={movie.title} <div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
loading="eager" <div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
/> <Image
</PosterContainer> src={getImageUrl(movie.poster_path, 'w500')}
alt={`Постер фильма ${movie.title}`}
fill
className="object-cover"
unoptimized
/>
</div>
</div>
</div>
<Details> {/* Middle Column: Details */}
<Title>{movie.title}</Title> <div className="md:col-span-2">
<Info> <h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
<InfoItem>Рейтинг: {movie.vote_average.toFixed(1)}</InfoItem> {movie.title}
<InfoItem>Длительность: {movie.runtime} мин.</InfoItem> </h1>
<InfoItem>Дата выхода: {formatDate(movie.release_date)}</InfoItem> {movie.tagline && (
</Info> <p className="mt-1 text-lg text-muted-foreground">{movie.tagline}</p>
<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>
)} )}
<FavoriteButton
mediaId={movie.id.toString()}
mediaType="movie"
title={movie.title}
posterPath={movie.poster_path}
/>
</ActionButtons>
</Details>
</MovieInfo>
{imdbId && ( <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
<PlayerSection id="movie-player"> <span className="font-medium">Рейтинг: {movie.vote_average.toFixed(1)}</span>
<MoviePlayer <span className="text-muted-foreground">|</span>
imdbId={imdbId} <span className="text-muted-foreground">{movie.runtime} мин.</span>
/> <span className="text-muted-foreground">|</span>
</PlayerSection> <span className="text-muted-foreground">{formatDate(movie.release_date)}</span>
)} </div>
</Content>
</Container> <div className="mt-4 flex flex-wrap gap-2">
{movie.genres.map((genre) => (
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
{genre.name}
</span>
))}
</div>
<div className="mt-6 space-y-4 text-base text-muted-foreground">
<p>{movie.overview}</p>
</div>
<div className="mt-8 flex items-center gap-4">
{/* Mobile-only Watch Button */}
{imdbId && (
<button
onClick={handleOpenPlayer}
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
>
<PlayCircle size={20} />
<span>Смотреть</span>
</button>
)}
<FavoriteButton
mediaId={movie.id.toString()}
mediaType="movie"
title={movie.title}
posterPath={movie.poster_path}
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
/>
</div>
{/* Desktop-only Embedded Player */}
{imdbId && (
<div id="movie-player" className="mt-10 hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
<MoviePlayer
id={movie.id.toString()}
title={movie.title}
poster={movie.poster_path || ''}
imdbId={imdbId}
/>
</div>
)}
</div>
</div>
</div>
</div>
{/* Fullscreen Player for Mobile */}
{isPlayerFullscreen && imdbId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
onMouseMove={showControls}
onClick={showControls}
>
<MoviePlayer
id={movie.id.toString()}
title={movie.title}
poster={movie.poster_path || ''}
imdbId={imdbId}
/>
<button
onClick={handleClosePlayer}
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
aria-label="Назад"
>
<ArrowLeft size={24} />
</button>
</div>
)}
</>
); );
} }

View File

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

View File

@@ -1,178 +1,20 @@
'use client'; "use client";
import Link from 'next/link'; 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;
`;
export default function NotFound() { export default function NotFound() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return ( return (
<Container> <div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
{isClient && ( <div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8 text-center">
<GlowingBackground className={isClient ? 'visible' : ''}> <h1 className="text-5xl font-bold mb-4">404</h1>
<Glow1 /> <p className="text-lg text-muted-foreground mb-6">Страница не найдена</p>
<Glow2 /> <Link
<Glow3 /> href="/"
</GlowingBackground> 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"
)} >
На главную
<Content> </Link>
<GlassCard> </div>
<ErrorCode>404</ErrorCode> </div>
<Title>Упс... Страница не найдена</Title>
<Description>
К сожалению, запрашиваемая страница не найдена.
<br />
Возможно, она была удалена или перемещена.
</Description>
<HomeButton href="/">Вернуться на главную</HomeButton>
</GlassCard>
</Content>
</Container>
); );
} }

View File

@@ -1,184 +1,87 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import { useMovies, MovieCategory } from '@/hooks/useMovies';
import styled from 'styled-components'; import MovieTile from '@/components/MovieTile';
import { HeartIcon } from '@/components/Icons/HeartIcon';
import MovieCard from '@/components/MovieCard';
import { useMovies } from '@/hooks/useMovies';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import { getImageUrl } from '@/lib/neoApi'; import HorizontalSlider from '@/components/HorizontalSlider';
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;
`;
export default function HomePage() { export default function HomePage() {
const { movies, featuredMovie, loading, error, totalPages, currentPage, setPage } = useMovies(1); const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
const [selectedGenre, setSelectedGenre] = useState<string | null>(null); const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
if (loading && !movies.length) { if (loading && !movies.length) {
return ( return (
<Container> <div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
<div>Загрузка...</div> Загрузка...
</Container> </div>
); );
} }
if (error) { if (error) {
return ( return (
<Container> <div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-red-500">
<div>{error}</div> {error}
</Container> </div>
); );
} }
const filteredMovies = selectedGenre const sliderMovies = movies.slice(0, 10);
? movies.filter(movie => movie.genre_ids.includes(parseInt(selectedGenre)))
: movies;
return ( return (
<Container> <main className="min-h-screen bg-background px-4 py-6 text-foreground md:px-6 lg:px-8">
{featuredMovie && ( <div className="container mx-auto">
<FeaturedMovie $backdrop={getImageUrl(featuredMovie.backdrop_path, 'original')}> <div className="border-b border-gray-200 dark:border-gray-700">
<Overlay> <nav className="-mb-px flex space-x-8" aria-label="Tabs">
<FeaturedContent> <button
<GenreTags> onClick={() => setActiveTab('popular')}
{featuredMovie.genres?.map(genre => ( className={`${
<GenreTag key={genre.id}>{genre.name}</GenreTag> activeTab === 'popular'
))} ? 'border-red-500 text-red-600'
</GenreTags> : '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'
<Title>{featuredMovie.title}</Title> } whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
<Overview>{featuredMovie.overview}</Overview> >
<ButtonContainer> Популярные
<Link href={`/movie/${featuredMovie.id}`}> </button>
<WatchButton>Смотреть</WatchButton> <button
</Link> onClick={() => setActiveTab('now_playing')}
<FavoriteButton className={`${
mediaId={featuredMovie.id.toString()} activeTab === 'now_playing'
mediaType="movie" ? 'border-red-500 text-red-600'
title={featuredMovie.title} : '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'
posterPath={featuredMovie.poster_path} } whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
/> >
</ButtonContainer> Новинки
</FeaturedContent> </button>
</Overlay> <button
</FeaturedMovie> 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> <div className="mt-6">
{filteredMovies.map(movie => ( <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">
<MovieCard key={movie.id} movie={movie} /> {movies.map((movie) => (
))} <MovieTile key={movie.id} movie={movie} />
</MoviesGrid> ))}
</div>
</div>
<Pagination <div className="mt-8 flex justify-center">
currentPage={currentPage} <Pagination
totalPages={totalPages} currentPage={currentPage}
onPageChange={setPage} totalPages={totalPages}
/> onPageChange={setPage}
</Container> />
</div>
</div>
</main>
); );
} }

View File

@@ -1,73 +1,9 @@
'use client'; 'use client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import styled from 'styled-components';
import GlassCard from '@/components/GlassCard';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2, LogOut } from 'lucide-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;
}
`;
export default function ProfilePage() { export default function ProfilePage() {
const { logout } = useAuth(); const { logout } = useAuth();
@@ -93,34 +29,38 @@ export default function ProfilePage() {
if (loading) { if (loading) {
return ( return (
<Container> <div className="flex min-h-screen w-full items-center justify-center bg-[#F9F6EE] dark:bg-gray-900">
<Content> <Loader2 className="h-16 w-16 animate-spin text-red-500" />
<GlassCard> </div>
<div>Загрузка...</div>
</GlassCard>
</Content>
</Container>
); );
} }
if (!userName) { if (!userName) {
// This can happen briefly before redirect, or if localStorage is cleared.
return null; return null;
} }
return ( return (
<Container> <div className="min-h-screen w-full bg-[#F9F6EE] dark:bg-[#1e1e1e] pt-24 sm:pt-32">
<Content> <div className="flex justify-center px-4">
<GlassCard> <div className="w-full max-w-md rounded-2xl bg-white dark:bg-[#49372E] p-8 shadow-lg">
<ProfileHeader> <div className="flex flex-col items-center text-center">
<Avatar> <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() || ''} {userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</Avatar> </div>
<Name>{userName}</Name> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{userName}</h1>
<Email>{userEmail}</Email> <p className="mt-2 text-base text-gray-500 dark:text-gray-300">{userEmail}</p>
<SignOutButton onClick={handleSignOut}>Выйти</SignOutButton> <button
</ProfileHeader> onClick={handleSignOut}
</GlassCard> 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"
</Content> >
</Container> <LogOut size={20} />
<span>Выйти</span>
</button>
</div>
</div>
</div>
</div>
); );
} }

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

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

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { tvAPI } from '@/lib/api';
import { getImageUrl } from '@/lib/neoApi';
import type { TVShowDetails } from '@/lib/api';
import MoviePlayer from '@/components/MoviePlayer';
import FavoriteButton from '@/components/FavoriteButton';
import { formatDate } from '@/lib/utils';
import { PlayCircle, ArrowLeft } from 'lucide-react';
interface TVContentProps {
showId: string;
initialShow: TVShowDetails;
}
export default function TVContent({ showId, initialShow }: TVContentProps) {
const [show] = useState<TVShowDetails>(initialShow);
const [imdbId, setImdbId] = useState<string | null>(null);
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
const [isControlsVisible, setIsControlsVisible] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const fetchImdbId = async () => {
try {
// Используем dedicated эндпоинт для получения IMDb ID
const { data } = await tvAPI.getImdbId(showId);
if (data?.imdb_id) {
setImdbId(data.imdb_id);
}
} catch (err) {
console.error('Error fetching IMDb ID:', err);
}
};
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
if (initialShow.external_ids?.imdb_id) {
setImdbId(initialShow.external_ids.imdb_id);
} else {
fetchImdbId();
}
}, [showId, initialShow.external_ids]);
const showControls = () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
setIsControlsVisible(true);
controlsTimeoutRef.current = setTimeout(() => {
setIsControlsVisible(false);
}, 3000);
};
const handleOpenPlayer = () => {
setIsPlayerFullscreen(true);
showControls();
};
const handleClosePlayer = () => {
setIsPlayerFullscreen(false);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
return (
<>
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
<div className="w-full">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="md:col-span-1">
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
<Image
src={getImageUrl(show.poster_path, 'w500')}
alt={`Постер сериала ${show.name}`}
fill
className="object-cover"
unoptimized
/>
</div>
</div>
</div>
<div className="md:col-span-2">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
{show.name}
</h1>
{show.tagline && (
<p className="mt-1 text-lg text-muted-foreground">{show.tagline}</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
<span className="font-medium">Рейтинг: {show.vote_average.toFixed(1)}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">Сезонов: {show.number_of_seasons}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{formatDate(show.first_air_date)}</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{show.genres.map((genre) => (
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
{genre.name}
</span>
))}
</div>
<div className="mt-6 space-y-4 text-base text-muted-foreground">
<p>{show.overview}</p>
</div>
<div className="mt-8 flex items-center gap-4">
{imdbId && (
<button
onClick={handleOpenPlayer}
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
>
<PlayCircle size={20} />
<span>Смотреть</span>
</button>
)}
<FavoriteButton
mediaId={show.id.toString()}
mediaType="tv"
title={show.name}
posterPath={show.poster_path}
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
/>
</div>
{imdbId && (
<div id="movie-player" className="mt-10 hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
<MoviePlayer
id={show.id.toString()}
title={show.name}
poster={show.poster_path || ''}
imdbId={imdbId}
/>
</div>
)}
</div>
</div>
</div>
</div>
{isPlayerFullscreen && imdbId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
onMouseMove={showControls}
onClick={showControls}
>
<MoviePlayer
id={show.id.toString()}
title={show.name}
poster={show.poster_path || ''}
imdbId={imdbId}
isFullscreen={true}
/>
<button
onClick={handleClosePlayer}
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
aria-label="Назад"
>
<ArrowLeft size={24} />
</button>
</div>
)}
</>
);
}

View File

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

View File

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

View File

@@ -1,142 +1,54 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '../../hooks/useAuth'; 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` export default function VerificationClient() {
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 }) {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [countdown, setCountdown] = useState(60);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0); const [isResending, setIsResending] = useState(false);
const router = useRouter(); const router = useRouter();
const { verifyCode, login } = useAuth();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const email = searchParams.get('email');
const { verifyCode, login } = useAuth();
useEffect(() => { useEffect(() => {
if (!email) {
router.replace('/login');
return;
}
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (countdown > 0) { if (countdown > 0) {
timer = setInterval(() => { timer = setInterval(() => {
setCountdown((prev) => prev - 1); setCountdown((prev) => prev - 1);
}, 1000); }, 1000);
} }
return () => { return () => {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
}; };
}, [countdown]); }, [countdown, email, router]);
const handleVerify = async () => { const handleSubmit = async (e: React.FormEvent) => {
if (code.length !== 6) { e.preventDefault();
setError('Код должен состоять из 6 цифр');
return;
}
setIsLoading(true);
setError(''); setError('');
setIsLoading(true);
try { try {
const password = localStorage.getItem('password');
if (!password || !email) {
throw new Error('Не удалось получить данные для входа');
}
await verifyCode(code); await verifyCode(code);
await login(email, password);
localStorage.removeItem('password');
router.replace('/');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка'); setError(err instanceof Error ? err.message : 'Произошла ошибка');
} finally { } 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 { try {
await authAPI.resendCode(email); await authAPI.resendCode(email);
setCountdown(60); setCountdown(60);
} catch (err) { } catch (err) {
setError('Не удалось отправить код'); setError(err instanceof Error ? err.message : 'Не удалось отправить код');
} finally {
setIsResending(false);
} }
}; };
return ( return (
<Container> <div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
<div> <h2 className="text-xl font-bold text-center mb-2 text-foreground">Подтверждение email</h2>
<Title>Подтвердите ваш email</Title> <p className="text-muted-foreground text-center mb-8">
<Subtitle>Мы отправили код подтверждения на {email}</Subtitle> Мы отправили код подтверждения на {email}
</div> </p>
<div> <form onSubmit={handleSubmit} className="space-y-6">
<CodeInput <div>
type="text" <input
maxLength={6} type="text"
value={code} placeholder="Введите код"
onChange={(e) => { value={code}
const value = e.target.value.replace(/\D/g, ''); onChange={(e) => setCode(e.target.value)}
setCode(value); maxLength={6}
setError(''); 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"
placeholder="Введите код" />
/> </div>
{error && <ErrorMessage>{error}</ErrorMessage>}
</div>
<div> <button
<VerifyButton type="submit"
onClick={handleVerify}
disabled={isLoading || code.length !== 6} 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 ? 'Проверка...' : 'Подтвердить'} {isLoading ? 'Проверка...' : 'Подтвердить'}
</VerifyButton> </button>
</form>
<ResendButton {error && (
onClick={handleResend} <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">
disabled={countdown > 0 || isLoading} {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 {isResending
? `Отправить код повторно (${countdown}с)` ? 'Отправка...'
: countdown > 0
? `Отправить код повторно через ${countdown} сек`
: 'Отправить код повторно'} : 'Отправить код повторно'}
</ResendButton> </button>
</div> </div>
</Container> </div>
); );
} }

View File

@@ -1,119 +1,15 @@
'use client'; "use client";
import { GlassCard } from '@/components/GlassCard'; import dynamic from 'next/dynamic';
import { VerificationClient } from './VerificationClient';
import styled from 'styled-components';
import { useSearchParams, useRouter } from 'next/navigation';
import { useEffect, Suspense } from 'react';
const Container = styled.div` const VerificationClient = dynamic(() => import('./VerificationClient'), {
min-height: 100vh; ssr: false
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;
}
export default function VerifyPage() {
return ( return (
<Container> <div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
<GlowingBackground> <VerificationClient />
<Glow1 /> </div>
<Glow2 />
<Glow3 />
</GlowingBackground>
<Content>
<GlassCard>
<VerificationClient email={email} />
</GlassCard>
</Content>
</Container>
);
}
export default function VerificationPage() {
return (
<Suspense>
<VerifyContent />
</Suspense>
); );
} }

View File

@@ -1,29 +1,19 @@
'use client'; 'use client';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import styled from 'styled-components';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Navbar from './Navbar';
const Layout = styled.div<{ $hasNavbar: boolean }>` const Layout = ({ children }: { children: ReactNode }) => (
min-height: 100vh; <div className="min-h-screen flex bg-gray-900 text-white">
display: flex; {children}
background: #0E0E0E; </div>
`; );
const Main = styled.main<{ $hasNavbar: boolean }>` const Main = ({ children }: { children: ReactNode }) => (
flex: 1; <main className="flex-1 p-4 md:p-6 lg:p-8">
padding: 20px; {children}
</main>
${props => props.$hasNavbar && ` );
@media (max-width: 768px) {
margin-top: 60px;
}
@media (min-width: 769px) {
margin-left: 240px;
}
`}
`;
interface AppLayoutProps { interface AppLayoutProps {
children: ReactNode; children: ReactNode;
@@ -31,12 +21,15 @@ interface AppLayoutProps {
export default function AppLayout({ children }: AppLayoutProps) { export default function AppLayout({ children }: AppLayoutProps) {
const pathname = usePathname(); const pathname = usePathname();
const hideNavbar = pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify'); const hideLayout = pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify');
if (hideLayout) {
return <>{children}</>;
}
return ( return (
<Layout $hasNavbar={!hideNavbar}> <Layout>
{!hideNavbar && <Navbar />} <Main>{children}</Main>
<Main $hasNavbar={!hideNavbar}>{children}</Main>
</Layout> </Layout>
); );
} }

View File

@@ -1,13 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import { Category } from '@/lib/api'; import { Category } from '@/lib/api';
interface CategoryCardProps { interface CategoryCardProps {
category: Category; category: Category;
backgroundUrl?: string; backgroundUrl?: string | null;
} }
// Словарь цветов для разных жанров // Словарь цветов для разных жанров
@@ -47,69 +46,9 @@ function getCategoryColor(categoryId: number): string {
return genreColors[categoryId] || '#3949AB'; // Индиго как запасной вариант return genreColors[categoryId] || '#3949AB'; // Индиго как запасной вариант
} }
const CardContainer = styled.div<{ $bgUrl: string; $bgColor: string }>`
position: relative;
width: 100%;
height: 180px;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease;
background-image: url(${props => props.$bgUrl || '/images/placeholder.jpg'});
background-size: cover;
background-position: center;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${props => props.$bgColor};
opacity: 0.7;
transition: opacity 0.3s ease;
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
&::after {
opacity: 0.8;
}
}
`;
const CardContent = styled.div`
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 1rem;
color: white;
text-align: center;
`;
const CategoryName = styled.h3`
font-size: 1.5rem;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
`;
const CategoryCount = styled.p`
font-size: 0.875rem;
opacity: 0.9;
margin: 0.5rem 0 0;
`;
function CategoryCard({ category, backgroundUrl }: CategoryCardProps) { function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
const router = useRouter(); const router = useRouter();
const [imageUrl, setImageUrl] = useState<string>(backgroundUrl || '/images/placeholder.jpg'); const [imageUrl] = useState<string>(backgroundUrl || '/images/placeholder.jpg');
const categoryColor = getCategoryColor(category.id); const categoryColor = getCategoryColor(category.id);
@@ -118,18 +57,22 @@ function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
} }
return ( return (
<CardContainer <div
$bgUrl={imageUrl}
$bgColor={categoryColor}
onClick={handleClick} onClick={handleClick}
role="button" role="button"
aria-label={`Категория ${category.name}`} aria-label={`Категория ${category.name}`}
className="relative w-full h-44 rounded-xl overflow-hidden cursor-pointer transition-transform duration-300 ease-in-out hover:-translate-y-1.5 hover:shadow-2xl bg-cover bg-center group"
style={{ backgroundImage: `url(${imageUrl})` }}
> >
<CardContent> <div
<CategoryName>{category.name}</CategoryName> className="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-70 group-hover:opacity-80"
<CategoryCount>Фильмы и сериалы</CategoryCount> style={{ backgroundColor: categoryColor }}
</CardContent> ></div>
</CardContainer> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent transition-opacity duration-300" />
<div className="relative z-10 flex flex-col justify-center items-center h-full p-4 text-white text-center">
<h3 className="text-2xl font-bold m-0 drop-shadow-lg">{category.name}</h3>
</div>
</div>
); );
} }

View File

@@ -1,27 +1,22 @@
'use client'; 'use client';
import HeaderBar from './HeaderBar';
import { ThemeProvider } from 'styled-components';
import StyledComponentsRegistry from '@/lib/registry';
import Navbar from './Navbar';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { ThemeProvider } from './ThemeProvider';
const theme = { import { useState } from 'react';
colors: { import MobileNav from './MobileNav';
primary: '#3b82f6',
background: '#0f172a',
text: '#ffffff',
},
};
export function ClientLayout({ children }: { children: React.ReactNode }) { export function ClientLayout({ children }: { children: React.ReactNode }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return ( return (
<StyledComponentsRegistry> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<ThemeProvider theme={theme}> <div className="flex flex-col min-h-screen">
<Navbar /> <HeaderBar onBurgerClick={() => setIsMobileMenuOpen(true)} />
{children} <MobileNav show={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
<Toaster position="bottom-right" /> <main className="flex-1 w-full">{children}</main>
</ThemeProvider> <Toaster position="bottom-right" />
</StyledComponentsRegistry> </div>
</ThemeProvider>
); );
} }

View File

@@ -1,33 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { favoritesAPI } from '@/lib/favoritesApi'; import { favoritesAPI } from '@/lib/favoritesApi';
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import styled from 'styled-components';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import cn from 'classnames';
const Button = styled.button<{ $isFavorite: boolean }>`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'};
border: none;
color: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
}
svg {
width: 1.2rem;
height: 1.2rem;
fill: ${props => props.$isFavorite ? '#ff4444' : 'none'};
stroke: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
transition: all 0.2s;
}
`;
interface FavoriteButtonProps { interface FavoriteButtonProps {
mediaId: string | number; mediaId: string | number;
@@ -41,13 +16,11 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
// Преобразуем mediaId в строку для сравнения
const mediaIdString = mediaId.toString(); const mediaIdString = mediaId.toString();
useEffect(() => { useEffect(() => {
const checkFavorite = async () => { const checkFavorite = async () => {
if (!token) return; if (!token) return;
try { try {
const { data } = await favoritesAPI.checkFavorite(mediaIdString); const { data } = await favoritesAPI.checkFavorite(mediaIdString);
setIsFavorite(!!data.isFavorite); setIsFavorite(!!data.isFavorite);
@@ -55,7 +28,6 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
console.error('Error checking favorite status:', error); console.error('Error checking favorite status:', error);
} }
}; };
checkFavorite(); checkFavorite();
}, [token, mediaIdString]); }, [token, mediaIdString]);
@@ -86,10 +58,19 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
} }
}; };
const buttonClasses = cn(
'flex items-center gap-2 rounded-md px-4 py-3 text-base font-semibold shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
{
'bg-red-100 text-red-700 hover:bg-red-200 focus-visible:outline-red-600': isFavorite,
'bg-warm-200 text-warm-800 hover:bg-warm-300 focus-visible:outline-warm-400': !isFavorite,
},
className
);
return ( return (
<Button type="button" onClick={toggleFavorite} $isFavorite={isFavorite} className={className}> <button type="button" onClick={toggleFavorite} className={buttonClasses}>
<Heart /> <Heart size={20} className={cn({ 'fill-current': isFavorite })} />
{isFavorite ? 'В избранном' : 'В избранное'} <span>{isFavorite ? 'В избранном' : 'В избранное'}</span>
</Button> </button>
); );
} }

View File

@@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { useTheme } from 'next-themes';
import { Search, Sun, Moon, User, Menu, Settings } from "lucide-react";
import { usePathname, useRouter } from 'next/navigation';
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link href={href} className={`text-sm font-medium transition-colors ${isActive ? 'text-accent-orange font-semibold' : 'text-gray-500 dark:text-gray-400 hover:text-accent-orange dark:hover:text-accent-orange'}`}>
{children}
</Link>
);
};
const ThemeToggleButton = () => {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="w-9 h-9" />; // placeholder
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-300 hover:text-black dark:hover:text-white transition-colors"
aria-label="Toggle theme"
>
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
</button>
);
};
export default function HeaderBar({ onBurgerClick }: { onBurgerClick?: () => void }) {
const [userName, setUserName] = useState<string | null>(
typeof window !== 'undefined' ? localStorage.getItem('userName') : null
);
const [query, setQuery] = useState("");
const router = useRouter();
useEffect(() => {
const handler = () => setUserName(localStorage.getItem('userName'));
window.addEventListener('auth-changed', handler);
return () => window.removeEventListener('auth-changed', handler);
}, []);
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
setQuery('');
}
};
return (
<header className="sticky top-0 z-50 bg-white dark:bg-[#1a1a1a] text-gray-800 dark:text-white shadow-md">
<div className="w-full px-4 sm:px-6 lg:px-8">
{/* Top bar */}
<div className="flex items-center justify-between h-14">
<div className="flex items-center space-x-6">
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-white">
Neo<span className="text-red-500">Movies</span>
</Link>
</div>
<form onSubmit={handleSearch} className="flex-1 max-w-xl mx-8">
<div className="relative">
<input
type="text"
placeholder="Поиск фильмов и сериалов..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-full py-2 pl-10 pr-4 text-sm text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
</div>
</form>
<div className="flex items-center space-x-4">
<ThemeToggleButton />
{userName ? (
<div className="flex items-center space-x-2">
<Link href="/settings" className="p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<Settings size={20} className="text-gray-800 dark:text-gray-300 hover:text-accent-orange" />
</Link>
<Link href="/profile" className="flex items-center space-x-2 p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<User size={20} className="text-gray-800 dark:text-gray-300" />
<span className="text-sm font-medium hidden sm:block text-gray-800 dark:text-white">{userName}</span>
</Link>
</div>
) : (
<Link href="/login" className="text-sm font-medium p-2 rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors">
Вход
</Link>
)}
<button onClick={onBurgerClick} className="md:hidden p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">
<Menu size={20} className="text-gray-800 dark:text-gray-300" />
</button>
</div>
</div>
{/* Bottom bar */}
<div className="hidden md:flex items-center justify-center h-12 border-t border-gray-200 dark:border-gray-800">
<nav className="flex items-center space-x-8">
<NavLink href="/">Фильмы</NavLink>
<NavLink href="/categories">Категории</NavLink>
<NavLink href="/favorites">Избранное</NavLink>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { ReactNode, useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
export default function HorizontalSlider({ children, title }: { children: ReactNode; title: string }) {
const ref = useRef<HTMLDivElement>(null);
const scroll = (dir: "left" | "right") => {
const el = ref.current;
if (!el) return;
const scrollAmount = 300;
el.scrollBy({ left: dir === "left" ? -scrollAmount : scrollAmount, behavior: "smooth" });
};
return (
<section className="mb-8">
<div className="mb-3 flex items-center justify-between px-1">
<h2 className="text-lg font-semibold text-warm-900">{title}</h2>
<div className="hidden gap-1 md:flex">
<button onClick={() => scroll("left")}
className="rounded-md bg-warm-200 p-1 text-warm-700 shadow-sm hover:bg-warm-300">
<ChevronLeft size={20} />
</button>
<button onClick={() => scroll("right")}
className="rounded-md bg-warm-200 p-1 text-warm-700 shadow-sm hover:bg-warm-300">
<ChevronRight size={20} />
</button>
</div>
</div>
<div
ref={ref}
className="flex gap-3 overflow-x-auto pb-2 [&::-webkit-scrollbar]:hidden md:gap-4"
>
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import Link from 'next/link';
import { X, Home, Clapperboard, Star } from 'lucide-react';
const NavLink = ({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void; }) => (
<Link href={href} onClick={onClick} className="flex items-center gap-4 p-4 text-lg rounded-md text-gray-300 hover:bg-gray-800">
{children}
</Link>
);
export default function MobileNav({ show, onClose }: { show: boolean; onClose: () => void; }) {
if (!show) return null;
return (
<div
className="fixed inset-0 bg-black/60 z-50 md:hidden"
onClick={onClose}
>
<div
className="absolute top-0 right-0 h-full w-4/5 max-w-sm bg-[#1a1a1a] shadow-xl flex flex-col p-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-semibold text-white">Меню</h2>
<button onClick={onClose} className="p-2 text-white">
<X size={24} />
</button>
</div>
<nav className="flex flex-col gap-2">
<NavLink href="/" onClick={onClose}><Home size={20}/>Фильмы</NavLink>
<NavLink href="/categories" onClick={onClose}><Clapperboard size={20}/>Категории</NavLink>
<NavLink href="/favorites" onClick={onClose}><Star size={20}/>Избранное</NavLink>
</nav>
</div>
</div>
);
}

View File

@@ -3,7 +3,6 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import styled from 'styled-components';
import { Movie, TVShow } from '@/types/movie'; import { Movie, TVShow } from '@/types/movie';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
import { useImageLoader } from '@/hooks/useImageLoader'; import { useImageLoader } from '@/hooks/useImageLoader';
@@ -18,6 +17,12 @@ interface MovieCardProps {
priority?: boolean; priority?: boolean;
} }
const getRatingColor = (rating: number) => {
if (rating >= 7) return 'bg-green-600';
if (rating >= 5) return 'bg-yellow-500';
return 'bg-red-600';
};
export default function MovieCard({ movie, priority = false }: MovieCardProps) { export default function MovieCard({ movie, priority = false }: MovieCardProps) {
// Определяем, это фильм или сериал с помощью тип-гарда // Определяем, это фильм или сериал с помощью тип-гарда
const isTV = isTVShow(movie); const isTV = isTVShow(movie);
@@ -34,158 +39,39 @@ export default function MovieCard({ movie, priority = false }: MovieCardProps) {
const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); // Используем поддерживаемый размер const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); // Используем поддерживаемый размер
return ( return (
<Card href={url}> <Link href={url} className="group relative flex h-full flex-col overflow-hidden rounded-lg bg-card text-card-foreground shadow-md transition-transform duration-300 ease-in-out will-change-transform hover:scale-105">
<PosterWrapper> <div className="relative aspect-[2/3]">
{isLoading ? ( {isLoading ? (
<LoadingPlaceholder aria-label="Загрузка постера"> <div className="flex h-full w-full items-center justify-center bg-muted">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/50 border-t-primary" />
</LoadingPlaceholder> </div>
) : imageUrl ? ( ) : imageUrl ? (
<Poster <Image
src={imageUrl} src={imageUrl}
alt={`Постер ${title}`} alt={`Постер ${title}`}
fill fill
sizes="(max-width: 640px) 150px, (max-width: 768px) 180px, (max-width: 1024px) 200px, 220px" sizes="(max-width: 640px) 150px, (max-width: 768px) 180px, 220px"
priority={priority} priority={priority}
loading={priority ? 'eager' : 'lazy'} loading={priority ? 'eager' : 'lazy'}
className="object-cover" className="object-cover"
unoptimized // Отключаем оптимизацию Next.js, так как используем CDN unoptimized
/> />
) : ( ) : (
<NoImagePlaceholder aria-label="Нет изображения"> <div className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /> <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
</NoImagePlaceholder> </div>
)} )}
<Rating style={{ backgroundColor: getRatingColor(movie.vote_average) }}> <div className={`absolute top-2 right-2 z-10 rounded-md px-2 py-1 text-xs font-semibold text-white shadow-lg ${getRatingColor(movie.vote_average)}`}>
{movie.vote_average.toFixed(1)} {movie.vote_average.toFixed(1)}
</Rating> </div>
</PosterWrapper> </div>
<Content> <div className="flex flex-1 flex-col p-3">
<Title>{title}</Title> <h3 className="mb-1 block truncate text-sm font-medium">{title}</h3>
<Year>{date ? formatDate(date) : 'Без даты'}</Year> <p className="text-xs text-muted-foreground">{date ? formatDate(date) : 'Без даты'}</p>
</Content> </div>
</Card> </Link>
); );
} }
// Функция для определения цвета рейтинга
const getRatingColor = (rating: number) => {
if (rating >= 7) return '#4CAF50';
if (rating >= 5) return '#FFC107';
return '#F44336';
};
// Оптимизированные стилевые компоненты для мобильных устройств
const Card = styled(Link)`
position: relative;
border-radius: 12px; /* Уменьшили радиус для компактности */
overflow: hidden;
background: #1c1c1c; /* Темнее фон для лучшего контраста */
text-decoration: none;
color: inherit;
will-change: transform; /* Подсказка браузеру для оптимизации */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex; /* Используем flexbox для лучшего контроля над высотой */
flex-direction: column;
height: 100%; /* Занимаем всю доступную высоту */
@media (max-width: 640px) {
border-radius: 8px; /* Еще меньше радиус на малых экранах */
}
`;
const PosterWrapper = styled.div`
position: relative;
aspect-ratio: 2/3;
`;
const Poster = styled(Image)`
width: 100%;
height: 100%;
`;
// Плейсхолдер для загрузки
const LoadingPlaceholder = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #2a2a2a;
`;
// Плейсхолдер для отсутствующих изображений
const NoImagePlaceholder = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #1c1c1c;
color: #6b7280;
`;
const Content = styled.div`
padding: 12px;
flex-grow: 1; /* Занимаем все оставшееся пространство */
display: flex;
flex-direction: column;
@media (max-width: 640px) {
padding: 8px 10px; /* Уменьшенные отступы для мобильных устройств */
}
`;
const Title = styled.h3`
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0 0 4px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: normal; /* Важно: разрешаем перенос текста */
max-height: 2.8em; /* Фиксированная высота для заголовка */
@media (max-width: 640px) {
font-size: 13px; /* Уменьшенный размер шрифта для мобильных устройств */
line-height: 1.3;
}
`;
const Year = styled.p`
font-size: 12px;
color: #aaa;
margin: 0;
@media (max-width: 640px) {
font-size: 11px; /* Уменьшенный размер шрифта для мобильных устройств */
}
`;
const Rating = styled.div`
position: absolute;
top: 8px;
right: 8px;
background-color: #2196F3;
color: white;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 2;
@media (max-width: 640px) {
padding: 2px 5px;
font-size: 11px;
top: 6px;
right: 6px;
border-radius: 3px;
}
`;

View File

@@ -1,131 +1,43 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import styled from 'styled-components';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { moviesAPI, api } from '@/lib/api'; import { moviesAPI, api } from '@/lib/api';
import { AlertTriangle, Info } from 'lucide-react';
const PlayerContainer = styled.div`
position: relative;
width: 100%;
height: 0;
padding-bottom: 56.25%;
background: #000;
border-radius: 12px;
overflow: hidden;
margin-bottom: 8px;
`;
const StyledIframe = styled.iframe`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
`;
const LoadingContainer = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
`;
const ErrorContainer = styled.div`
flex-direction: column;
gap: 1rem;
padding: 2rem;
text-align: center;
`;
const RetryButton = styled.button`
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
background: #3b82f6;
color: white;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #2563eb;
}
`;
const DownloadMessage = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(13, 37, 73, 0.8);
border: 1px solid rgba(33, 150, 243, 0.2);
border-radius: 8px;
color: rgba(33, 150, 243, 0.9);
font-size: 14px;
backdrop-filter: blur(10px);
svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
`;
interface MoviePlayerProps { interface MoviePlayerProps {
id: string; id: string;
title: string; title: string;
poster: string; poster: string;
imdbId?: string; imdbId?: string;
isFullscreen?: boolean;
} }
export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerProps) { export default function MoviePlayer({ id, title, poster, imdbId, isFullscreen = false }: MoviePlayerProps) {
const { settings, isInitialized } = useSettings(); const { settings, isInitialized } = useSettings();
// containerRef removed using direct iframe integration
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [iframeSrc, setIframeSrc] = useState<string | null>(null); const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null); const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null);
useEffect(() => {
if (isInitialized) {
// setCurrentPlayer(settings.defaultPlayer);
}
}, [settings.defaultPlayer, isInitialized]);
useEffect(() => { useEffect(() => {
const fetchImdbId = async () => { const fetchImdbId = async () => {
if (imdbId) return;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const { data } = await moviesAPI.getMovie(id);
if (!imdbId) { if (!data?.imdb_id) throw new Error('IMDb ID не найден');
const { data } = await moviesAPI.getMovie(id); setResolvedImdb(data.imdb_id);
if (!data?.imdb_id) {
throw new Error('IMDb ID не найден');
}
setResolvedImdb(data.imdb_id);
}
} catch (err) { } catch (err) {
console.error('Error fetching IMDb ID:', err); console.error('Error fetching IMDb ID:', err);
setError('Не удалось загрузить плеер. Пожалуйста, попробуйте позже.'); setError('Не удалось получить информацию для плеера.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchImdbId();
if (!resolvedImdb) { }, [id, imdbId]);
fetchImdbId();
}
}, [id, resolvedImdb]);
useEffect(() => { useEffect(() => {
const loadPlayer = async () => { const loadPlayer = async () => {
@@ -133,37 +45,17 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex'; const basePath = settings.defaultPlayer === 'alloha' ? '/players/alloha' : '/players/lumex';
const queryParams = { imdb_id: resolvedImdb }; const { data } = await api.get(basePath, { params: { imdb_id: resolvedImdb } });
if (!data) throw new Error('Empty response');
try { let src: string | null = data.iframe || data.src || data.url || null;
const response = await api.get(basePath, { params: queryParams }); if (!src && typeof data === 'string') {
if (!response.data) { const match = data.match(/<iframe[^>]*src="([^"]+)"/i);
throw new Error('Empty response'); if (match && match[1]) src = match[1];
}
let src: string | null = null;
if (response.data.iframe) {
src = response.data.iframe;
} else if (response.data.src) {
src = response.data.src;
} else if (response.data.url) {
src = response.data.url;
} else if (typeof response.data === 'string') {
const match = response.data.match(/<iframe[^>]*src="([^"]+)"/i);
if (match && match[1]) src = match[1];
}
if (!src) {
throw new Error('Invalid response format');
}
setIframeSrc(src);
} catch (err) {
console.error(err);
setError('Не удалось загрузить плеер. Попробуйте позже.');
} finally {
setLoading(false);
} }
if (!src) throw new Error('Invalid response format');
setIframeSrc(src);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError('Не удалось загрузить плеер. Попробуйте позже.'); setError('Не удалось загрузить плеер. Попробуйте позже.');
@@ -171,44 +63,66 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
setLoading(false); setLoading(false);
} }
}; };
loadPlayer(); loadPlayer();
}, [id, resolvedImdb, isInitialized, settings.defaultPlayer]); }, [resolvedImdb, isInitialized, settings.defaultPlayer]);
const handleRetry = () => { const handleRetry = () => {
setLoading(true);
setError(null); setError(null);
if (!resolvedImdb) {
setLoading(false); // Re-fetch IMDb ID
const event = new Event('fetchImdb');
window.dispatchEvent(event);
} else {
// Re-load player
const event = new Event('loadPlayer');
window.dispatchEvent(event);
}
}; };
if (error) { if (error) {
return ( return (
<ErrorContainer> <div className="flex flex-col items-center justify-center gap-4 rounded-lg bg-red-100 p-6 text-center text-red-700">
<div>{error}</div> <AlertTriangle size={32} />
<RetryButton onClick={handleRetry}>Попробовать снова</RetryButton> <p>{error}</p>
</ErrorContainer> <button
onClick={handleRetry}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
>
Попробовать снова
</button>
</div>
); );
} }
return ( const rootClasses = isFullscreen ? 'w-full h-full' : '';
<> const playerContainerClasses = isFullscreen
<PlayerContainer> ? 'relative w-full h-full bg-black'
{iframeSrc ? ( : 'relative w-full overflow-hidden rounded-lg bg-black pt-[56.25%]';
<StyledIframe src={iframeSrc} allow="fullscreen" loading="lazy" />
) : (
loading && <LoadingContainer>Загрузка плеера...</LoadingContainer>
)}
</PlayerContainer>
{settings.defaultPlayer !== 'lumex' && (
<DownloadMessage>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Для возможности скачивания фильма выберите плеер Lumex в настройках
</DownloadMessage>
)}
</> return (
<div className={rootClasses}>
<div className={playerContainerClasses}>
{iframeSrc ? (
<iframe
src={iframeSrc}
allow="fullscreen"
loading="lazy"
className="absolute left-0 top-0 h-full w-full border-0"
/>
) : (
loading && (
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center text-warm-300">
Загрузка плеера...
</div>
)
)}
</div>
{settings.defaultPlayer !== 'lumex' && !isFullscreen && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-100 p-3 text-sm text-blue-800">
<Info size={20} />
<span>Для возможности скачивания фильма выберите плеер Lumex в настройках.</span>
</div>
)}
</div>
); );
} }

View File

@@ -0,0 +1,53 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { getImageUrl } from "@/lib/neoApi";
import { formatDate } from "@/lib/utils";
import FavoriteButton from "./FavoriteButton";
export interface MovieLike {
id: number;
poster_path: string | null;
title: string;
release_date?: string;
vote_average?: number;
}
export default function MovieTile({ movie }: { movie: MovieLike }) {
const fullDate = movie.release_date ? formatDate(movie.release_date) : "";
return (
<div className="w-full flex-shrink-0">
<div className="relative aspect-[2/3] overflow-hidden rounded-md bg-gray-200 dark:bg-gray-800 shadow-sm">
<Link href={`/movie/${movie.id}`}>
{movie.poster_path ? (
<Image
src={getImageUrl(movie.poster_path, "w342")}
alt={movie.title}
fill
className="object-cover transition-transform hover:scale-105"
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">
no image
</div>
)}
</Link>
<div className="absolute right-1 top-1 z-10">
<FavoriteButton
mediaId={movie.id.toString()}
mediaType="movie"
title={movie.title}
posterPath={movie.poster_path}
/>
</div>
</div>
<Link href={`/movie/${movie.id}`} className="mt-2 block text-sm font-medium leading-snug text-foreground hover:text-accent">
{movie.title}
</Link>
<span className="text-xs text-muted-foreground">
{fullDate} {movie.vote_average ? `· ${movie.vote_average.toFixed(1)}` : ""}
</span>
</div>
);
}

View File

@@ -1,461 +0,0 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '../hooks/useAuth';
import { useEffect } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import SearchModal from './SearchModal';
// Типы
type MenuItem = {
href?: string;
icon: React.ReactNode;
label: string;
onClick?: () => void;
};
// Компоненты
const DesktopSidebar = styled.aside`
display: none;
flex-direction: column;
width: 240px;
height: 100vh;
position: fixed;
left: 0;
top: 0;
background: rgba(18, 18, 23, 0.95);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
padding: 1rem;
z-index: 40;
@media (min-width: 769px) {
display: flex;
}
`;
const LogoContainer = styled.div`
padding: 0.5rem 1rem;
margin-bottom: 2rem;
`;
const MenuContainer = styled.nav`
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
`;
const SidebarMenuItem = styled.div<{ $active?: boolean }>`
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: ${props => props.$active ? 'white' : 'rgba(255, 255, 255, 0.7)'};
background: ${props => props.$active ? 'rgba(255, 255, 255, 0.1)' : 'transparent'};
text-decoration: none;
border-radius: 8px;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
svg {
width: 20px;
height: 20px;
}
`;
const MobileNav = styled.nav`
position: fixed;
top: 0;
left: 0;
right: 0;
background: #121217; /* Заменили полупрозрачный фон на сплошной для производительности */
/* Удалили тяжелый эффект blur для мобильных устройств */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); /* Добавили тень для визуального разделения */
z-index: 50;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
height: 56px; /* Уменьшили высоту для компактности */
@media (min-width: 769px) {
display: none;
}
`;
const Logo = styled(Link)`
font-size: 1.25rem;
font-weight: 600;
color: white;
text-decoration: none;
span {
color: #3b82f6;
}
`;
const MobileMenuButton = styled.button`
background: none;
border: none;
color: white;
padding: 0.5rem;
cursor: pointer;
svg {
width: 24px;
height: 24px;
}
`;
const MobileMenu = styled.div<{ $isOpen: boolean }>`
position: fixed;
top: 56px; /* Соответствует новой высоте навбара */
left: 0;
right: 0;
bottom: 0;
background: #121217; /* Сплошной фон без прозрачности */
/* Удалили тяжелый эффект blur */
transform: translateX(${props => props.$isOpen ? '0' : '100%'});
transition: transform 0.25s ease-out; /* Ускорили анимацию */
padding: 1rem;
z-index: 49;
overflow-y: auto;
will-change: transform; /* Подсказка браузеру для оптимизации */
-webkit-overflow-scrolling: touch; /* Плавный скролл на iOS */
@media (min-width: 769px) {
display: none;
}
`;
const MobileMenuItem = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.75rem; /* Уменьшили горизонтальные отступы */
margin-bottom: 0.25rem; /* Добавили отступ между элементами */
color: white;
text-decoration: none;
border-radius: 8px; /* Уменьшили радиус для компактности */
font-size: 1rem;
font-weight: 500; /* Добавили небольшое утолщение шрифта */
position: relative; /* Для анимации ripple-эффекта */
overflow: hidden; /* Для анимации ripple-эффекта */
/* Заменили плавную анимацию на мгновенную для мобильных устройств */
&:active {
background: rgba(255, 255, 255, 0.15);
transform: scale(0.98); /* Небольшой эффект нажатия */
}
svg {
width: 22px; /* Увеличили иконки для лучшей видимости на мобильных устройствах */
height: 22px;
min-width: 22px; /* Чтобы иконки были выровнены */
color: #3b82f6; /* Цвет для лучшего визуального разделения */
}
`;
const UserProfile = styled.div`
margin-top: auto;
padding: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
`;
const UserButton = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: none;
border-radius: 8px;
color: white;
width: 100%;
cursor: pointer;
`;
const UserAvatar = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
background: #3b82f6;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
flex-shrink: 0;
`;
const UserInfo = styled.div`
min-width: 0;
div:first-child {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
div:last-child {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const AuthButtons = styled.div`
margin-top: auto;
padding: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
export default function Navbar() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { logout } = useAuth();
const [token, setToken] = useState<string | null>(null);
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
const [mounted, setMounted] = useState(false);
// Читаем localStorage после монтирования
useEffect(() => {
const storedToken = localStorage.getItem('token');
setToken(storedToken);
if (storedToken) {
const lsName = localStorage.getItem('userName');
const lsEmail = localStorage.getItem('userEmail');
if (lsName) setUserName(lsName);
if (lsEmail) setUserEmail(lsEmail);
if (!lsName || !lsEmail) {
try {
const payload = JSON.parse(atob(storedToken.split('.')[1]));
const name = lsName || payload.name || payload.username || payload.userName || payload.sub || '';
const email = lsEmail || payload.email || '';
if (name) {
localStorage.setItem('userName', name);
setUserName(name);
}
if (email) {
localStorage.setItem('userEmail', email);
setUserEmail(email);
}
} catch {}
}
}
setMounted(true);
// слушаем события авторизации, чтобы обновлять ник без перезагрузки
const handleAuthChanged = () => {
const t = localStorage.getItem('token');
setToken(t);
setUserName(localStorage.getItem('userName') || '');
setUserEmail(localStorage.getItem('userEmail') || '');
};
window.addEventListener('auth-changed', handleAuthChanged);
return () => window.removeEventListener('auth-changed', handleAuthChanged);
}, []);
const pathname = usePathname();
// Ждём, пока компонент смонтируется, чтобы избежать гидрации с разными ветками
const router = useRouter();
// Скрываем навбар на определенных страницах
if (pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify')) {
return null;
}
const handleNavigation = (href: string, onClick?: () => void) => {
if (onClick) {
onClick();
} else if (href !== '#') {
router.push(href);
}
setIsMobileMenuOpen(false);
};
const menuItems = [
{
label: 'Главная',
href: '/',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
},
{
label: 'Поиск',
href: '#',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
onClick: () => setIsSearchOpen(true)
},
{
label: 'Категории',
href: '/categories',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
)
},
{
label: 'Избранное',
href: '/favorites',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
)
},
{
label: 'Настройки',
href: '/settings',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
];
return (
<>
{/* Desktop Sidebar */}
<DesktopSidebar>
<LogoContainer>
<Logo href="/">
Neo <span>Movies</span>
</Logo>
</LogoContainer>
<MenuContainer>
{menuItems.map((item, index) => (
<div
key={index}
onClick={() => handleNavigation(item.href, item.onClick)}
style={{ cursor: 'pointer' }}
>
<SidebarMenuItem
as="div"
$active={pathname === item.href}
>
{item.icon}
{item.label}
</SidebarMenuItem>
</div>
))}
</MenuContainer>
{token ? (
<UserProfile>
<UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}>
<UserAvatar>
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{userName}</div>
<div>{userEmail}</div>
</UserInfo>
</UserButton>
</UserProfile>
) : mounted ? (
<AuthButtons>
<div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
Войти
</MobileMenuItem>
</div>
</AuthButtons>
): null}
</DesktopSidebar>
{/* Mobile Navigation */}
<MobileNav>
<Logo href="/">
Neo <span>Movies</span>
</Logo>
<MobileMenuButton onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</MobileMenuButton>
</MobileNav>
{/* Mobile Menu */}
<MobileMenu $isOpen={isMobileMenuOpen}>
{token ? (
<UserProfile>
<UserButton onClick={() => { logout(); setIsMobileMenuOpen(false); }}>
<UserAvatar>
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{userName}</div>
<div>{userEmail}</div>
</UserInfo>
</UserButton>
</UserProfile>
) : null}
{menuItems.map((item, index) => (
<div
key={index}
onClick={() => handleNavigation(item.href, item.onClick)}
style={{ cursor: 'pointer' }}
>
<MobileMenuItem
as="div"
style={{
background: pathname === item.href ? 'rgba(255, 255, 255, 0.1)' : 'transparent'
}}
>
{item.icon}
{item.label}
</MobileMenuItem>
</div>
))}
{!token && (
<AuthButtons>
<div onClick={() => {
router.push('/login');
setIsMobileMenuOpen(false);
}} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
Войти
</MobileMenuItem>
</div>
</AuthButtons>
)}
</MobileMenu>
{/* Search Modal */}
{isSearchOpen && (
<SearchModal onClose={() => setIsSearchOpen(false)} />
)}
</>
);
}

View File

@@ -1,66 +1,7 @@
'use client'; 'use client';
import styled from 'styled-components';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import Navbar from './Navbar'; import Link from 'next/link';
const Layout = styled.div`
display: flex;
min-height: 100vh;
`;
const MainContent = styled.main<{ $isSettingsPage: boolean }>`
flex: 1;
margin-left: 220px;
padding: 0;
overflow: hidden;
${props => props.$isSettingsPage && `
display: flex;
justify-content: center;
padding-top: 2rem;
`}
@media (max-width: 768px) {
margin-left: 0;
padding-top: ${props => props.$isSettingsPage ? 'calc(60px + 2rem)' : '60px'};
}
`;
const NotFoundContent = styled.main`
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #0a0a0a;
color: white;
text-align: center;
padding: 2rem;
h1 {
font-size: 6rem;
margin: 0;
color: #2196f3;
}
p {
font-size: 1.5rem;
margin: 1rem 0 2rem;
color: rgba(255, 255, 255, 0.7);
}
a {
color: #2196f3;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
`;
export default function PageLayout({ children }: { children: React.ReactNode }) { export default function PageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -69,20 +10,21 @@ export default function PageLayout({ children }: { children: React.ReactNode })
if (is404Page) { if (is404Page) {
return ( return (
<NotFoundContent> <main className="flex-1 flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white text-center p-8">
<h1>404</h1> <h1 className="text-6xl font-bold m-0 text-blue-500">404</h1>
<p>Страница не найдена</p> <p className="text-2xl my-4 text-gray-300">Страница не найдена</p>
<a href="/">Вернуться на главную</a> <Link href="/" className="text-blue-500 font-medium hover:underline">
</NotFoundContent> Вернуться на главную
</Link>
</main>
); );
} }
return ( return (
<Layout> <div className="flex min-h-screen">
<Navbar /> <main className={`flex-1 overflow-hidden ${isSettingsPage ? 'flex justify-center pt-8' : ''}`}>
<MainContent $isSettingsPage={isSettingsPage}>
{children} {children}
</MainContent> </main>
</Layout> </div>
); );
} }

View File

@@ -1,39 +1,6 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import styled from 'styled-components';
const PaginationContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin: 2rem 0;
`;
const PageButton = styled.button<{ $active?: boolean }>`
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: ${props => props.$active ? props.theme.colors.primary : 'rgba(255, 255, 255, 0.1)'};
color: white;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: ${props => props.$active ? props.theme.colors.primary : 'rgba(255, 255, 255, 0.2)'};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const PageInfo = styled.span`
color: white;
padding: 0 1rem;
`;
interface PaginationProps { interface PaginationProps {
currentPage: number; currentPage: number;
@@ -41,6 +8,27 @@ interface PaginationProps {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
} }
const PageButton = ({ onClick, disabled, active, children }: {
onClick: () => void;
disabled?: boolean;
active?: boolean;
children: React.ReactNode;
}) => {
const baseClasses = 'px-3 py-1 rounded-md text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
const activeClasses = 'bg-accent text-white';
const inactiveClasses = 'bg-card hover:bg-card/80 text-foreground';
return (
<button
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${active ? activeClasses : inactiveClasses}`}
>
{children}
</button>
);
};
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) { export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
const maxVisiblePages = 5; const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2); const halfVisible = Math.floor(maxVisiblePages / 2);
@@ -57,7 +45,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
}; };
const handlePageClick = (page: number) => { const handlePageClick = (page: number) => {
if (page !== currentPage) { if (page !== currentPage && page > 0 && page <= totalPages) {
onPageChange(page); onPageChange(page);
} }
}; };
@@ -65,7 +53,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
if (totalPages <= 1) return null; if (totalPages <= 1) return null;
return ( return (
<PaginationContainer> <div className="flex items-center justify-center gap-2 my-8 text-foreground">
<PageButton <PageButton
onClick={() => handlePageClick(1)} onClick={() => handlePageClick(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
@@ -82,7 +70,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
{getPageNumbers().map(page => ( {getPageNumbers().map(page => (
<PageButton <PageButton
key={page} key={page}
$active={page === currentPage} active={page === currentPage}
onClick={() => handlePageClick(page)} onClick={() => handlePageClick(page)}
> >
{page} {page}
@@ -101,6 +89,6 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
> >
» »
</PageButton> </PageButton>
</PaginationContainer> </div>
); );
} }

View File

@@ -1,34 +1,7 @@
'use client'; 'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import { GlobalStyles } from '@/styles/GlobalStyles'; import { theme } from '@/styles/theme';
const theme = {
colors: {
primary: '#2196f3',
background: '#0a0a0a',
surface: '#1e1e1e',
text: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.7)',
error: '#ff5252',
success: '#4caf50',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
};
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
<SessionProvider refetchInterval={0}>
<ThemeProvider theme={theme}>
<GlobalStyles />
{children}
</ThemeProvider>
</SessionProvider>
);
} }

View File

@@ -1,177 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useRouter } from 'next/navigation';
import { Movie, TVShow } from '@/lib/api';
import SearchResults from './SearchResults';
const Overlay = styled.div<{ $isOpen: boolean }>`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: ${props => props.$isOpen ? 'flex' : 'none'};
justify-content: center;
align-items: flex-start;
padding-top: 100px;
z-index: 1000;
backdrop-filter: blur(5px);
`;
const Modal = styled.div`
width: 100%;
max-width: 600px;
background: rgba(30, 30, 30, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
position: relative;
`;
const SearchHeader = styled.div`
display: flex;
align-items: center;
padding: 1rem;
gap: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
`;
const SearchInput = styled.input`
flex: 1;
background: none;
border: none;
color: white;
font-size: 1rem;
outline: none;
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
`;
const CloseButton = styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
&:hover {
color: white;
}
`;
const SearchIcon = styled.div`
color: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
`;
const LoadingSpinner = styled.div`
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
interface SearchModalProps {
onClose: () => void;
}
export default function SearchModal({ onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
const [loading, setLoading] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
inputRef.current?.focus();
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
useEffect(() => {
const searchTimeout = setTimeout(async () => {
if (query.length < 2) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/movies/search?query=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results || []);
} catch (error) {
console.error('Error searching:', error);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(searchTimeout);
}, [query]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<Overlay $isOpen={true} onKeyDown={handleKeyDown}>
<Modal ref={modalRef}>
<SearchHeader>
<SearchIcon>
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</SearchIcon>
<SearchInput
ref={inputRef}
type="text"
placeholder="Поиск фильмов и сериалов..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{loading ? (
<LoadingSpinner />
) : (
<CloseButton onClick={onClose}>
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</CloseButton>
)}
</SearchHeader>
{results.length > 0 && <SearchResults results={results} onItemClick={onClose} />}
</Modal>
</Overlay>
);
}

View File

@@ -1,97 +0,0 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import Image from 'next/image';
import { getImageUrl } from '@/lib/neoApi';
import { Movie, TVShow } from '@/lib/api';
const ResultsContainer = styled.div`
max-height: 400px;
overflow-y: auto;
padding: 1rem;
`;
const ResultItem = styled.div`
display: flex;
padding: 0.75rem;
gap: 1rem;
text-decoration: none;
color: white;
transition: background-color 0.2s;
border-radius: 8px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
const PosterContainer = styled.div`
position: relative;
width: 45px;
height: 68px;
flex-shrink: 0;
border-radius: 0.25rem;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
`;
const ItemInfo = styled.div`
flex-grow: 1;
`;
const Title = styled.h3`
font-size: 1rem;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
`;
const Year = styled.span`
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
`;
interface SearchResultsProps {
results: (Movie | TVShow)[];
onItemClick: () => void;
}
const getYear = (date: string | undefined | null): string => {
if (!date) return '';
const year = date.split(' ')[2]; // Получаем год из формата "DD месяц YYYY г."
return year ? year : '';
};
export default function SearchResults({ results, onItemClick }: SearchResultsProps) {
return (
<ResultsContainer>
{results.map((item) => (
<Link
key={`${item.id}-${item.media_type}`}
href={`/${item.media_type}/${item.id}`}
onClick={onItemClick}
>
<ResultItem>
<PosterContainer>
<Image
src={item.poster_path ? getImageUrl(item.poster_path, 'w92') : '/images/placeholder.jpg'}
alt={item.title || item.name}
width={46}
height={69}
/>
</PosterContainer>
<ItemInfo>
<Title>{item.title || item.name}</Title>
<Year>
{getYear(item.release_date || item.first_air_date)}
</Year>
</ItemInfo>
</ResultItem>
</Link>
))}
</ResultsContainer>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -2,51 +2,46 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { moviesAPI } from '@/lib/neoApi'; import { moviesAPI } from '@/lib/neoApi';
import type { Movie } from '@/lib/neoApi'; import type { Movie, MovieResponse } from '@/lib/neoApi';
export function useMovies(initialPage = 1) { export type MovieCategory = 'popular' | 'top_rated' | 'now_playing';
interface UseMoviesProps {
initialPage?: number;
category?: MovieCategory;
}
export function useMovies({ initialPage = 1, category = 'popular' }: UseMoviesProps) {
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
const [featuredMovie, setFeaturedMovie] = useState<Movie | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(initialPage); const [page, setPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
// Получаем featured фильм всегда с первой страницы const fetchMovies = useCallback(async (pageNum: number, movieCategory: MovieCategory) => {
const fetchFeaturedMovie = useCallback(async () => {
try {
const response = await moviesAPI.getPopular(1);
if (response.data.results.length > 0) {
const firstMovie = response.data.results[0];
if (firstMovie.id) {
const movieDetails = await moviesAPI.getMovie(firstMovie.id);
setFeaturedMovie(movieDetails.data);
}
}
} catch (err) {
console.error('Ошибка при загрузке featured фильма:', err);
}
}, []);
// Загружаем фильмы для текущей страницы
const fetchMovies = useCallback(async (pageNum: number) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
setMovies([]); // Очищаем текущие фильмы перед загрузкой новых
console.log('Загрузка страницы:', pageNum); let response: { data: MovieResponse };
const response = await moviesAPI.getPopular(pageNum);
console.log('Получены данные:', { switch (movieCategory) {
page: response.data.page, case 'top_rated':
results: response.data.results.length, response = await moviesAPI.getTopRated(pageNum);
totalPages: response.data.total_pages break;
}); case 'now_playing':
response = await moviesAPI.getNowPlaying(pageNum);
break;
case 'popular':
default:
response = await moviesAPI.getPopular(pageNum);
break;
}
setMovies(response.data.results); setMovies(response.data.results);
setTotalPages(response.data.total_pages); setTotalPages(response.data.total_pages > 500 ? 500 : response.data.total_pages);
} catch (err) { } catch (err) {
console.error('Ошибка при загрузке фильмов:', err); console.error(`Ошибка при загрузке категории "${movieCategory}":`, err);
setError('Произошла ошибка при загрузке фильмов'); setError('Произошла ошибка при загрузке фильмов');
setMovies([]); setMovies([]);
} finally { } finally {
@@ -54,32 +49,27 @@ export function useMovies(initialPage = 1) {
} }
}, []); }, []);
// Загружаем featured фильм при монтировании
useEffect(() => { useEffect(() => {
fetchFeaturedMovie(); fetchMovies(page, category);
}, [fetchFeaturedMovie]); }, [page, category, fetchMovies]);
// Загружаем фильмы при изменении страницы // Сбрасываем страницу на 1 при смене категории
useEffect(() => { useEffect(() => {
console.log('Изменение страницы на:', page); setPage(1);
fetchMovies(page); }, [category]);
}, [page, fetchMovies]);
// Обработчик изменения страницы const handlePageChange = useCallback((newPage: number) => {
const handlePageChange = useCallback(async (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return; if (newPage < 1 || newPage > totalPages) return;
console.log('Смена страницы на:', newPage);
setPage(newPage); setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}, [totalPages]); }, [totalPages]);
return { return {
movies, movies,
featuredMovie,
loading, loading,
error, error,
totalPages, totalPages,
currentPage: page, currentPage: page,
setPage: handlePageChange setPage: handlePageChange,
}; };
} }

View File

@@ -159,6 +159,22 @@ export const moviesAPI = {
}); });
}, },
// Получение фильмов с высоким рейтингом
getTopRated(page = 1) {
return neoApi.get<MovieResponse>('/movies/top_rated', {
params: { page },
timeout: 30000
});
},
// Получение новинок
getNowPlaying(page = 1) {
return neoApi.get<MovieResponse>('/movies/now_playing', {
params: { page },
timeout: 30000
});
},
// Получение данных о фильме по его ID // Получение данных о фильме по его ID
getMovie(id: string | number) { getMovie(id: string | number) {
return neoApi.get(`/movies/${id}`, { timeout: 30000 }); return neoApi.get(`/movies/${id}`, { timeout: 30000 });

23
src/middleware.ts Normal file
View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// You can add your middleware logic here
// For example: authentication, redirects, headers modification, etc.
return NextResponse.next();
}
// Optionally configure paths that should use this middleware
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
],
};

47
src/styles/theme.ts Normal file
View File

@@ -0,0 +1,47 @@
import { DefaultTheme } from 'styled-components';
// Calm warm palette: sand / clay / terracotta accents
export const theme: DefaultTheme = {
colors: {
background: '#faf5f0', // light warm background for light mode
surface: '#fff8f3', // card/background surfaces
surfaceDark: '#1e1a16', // dark mode surface
text: '#2c261f', // primary text
textSecondary: '#6d6257', // secondary text
primary: '#e04e39', // warm red-orange accent (buttons)
primaryHover: '#c74430',
secondary: '#f9c784', // mellow orange highlight
border: '#e9ded7',
},
radius: {
xs: '4px',
sm: '6px',
md: '8px',
lg: '12px',
},
spacing: (n: number) => `${n * 4}px`,
};
declare module 'styled-components' {
export interface DefaultTheme {
colors: {
background: string;
surface: string;
surfaceDark: string;
text: string;
textSecondary: string;
primary: string;
primaryHover: string;
secondary: string;
border: string;
};
radius: {
xs: string;
sm: string;
md: string;
lg: string;
};
spacing: (n: number) => string;
}
}

View File

@@ -1,6 +1,8 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import plugin from "tailwindcss/plugin";
export default { export default {
darkMode: 'class',
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
@@ -11,8 +13,52 @@ export default {
colors: { colors: {
background: "var(--background)", background: "var(--background)",
foreground: "var(--foreground)", foreground: "var(--foreground)",
warm: {
50: '#fdf9f4',
100: '#faf5f0',
200: '#f2e6d9',
300: '#e9d6c2',
400: '#e0c6aa',
500: '#d7b792',
600: '#c49f71',
700: '#a67f55',
800: '#886040',
900: '#66452e',
},
accent: '#e04e39',
},
borderRadius: {
md: '8px',
lg: '12px',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translate(0, 0)' },
'50%': { transform: 'translate(-30px, 30px)' },
}
},
animation: {
'float': 'float 20s infinite ease-in-out',
'float-delayed': 'float 20s infinite ease-in-out -5s',
'float-more-delayed': 'float 20s infinite ease-in-out -10s',
}, },
}, },
}, },
plugins: [], plugins: [
plugin(({ addBase }) => {
addBase({
':root': {
'--background': '#fdf9f4', // warm-50
'--foreground': '#2c261f', // warm-900
},
'.dark': {
'--background': '#1c1c1c', // A dark gray
'--foreground': '#f2e6d9', // warm-200
},
body: {
'@apply bg-background text-foreground antialiased': {},
},
});
}),
],
} satisfies Config; } satisfies Config;