diff --git a/src/app/api/movies/[id]/external-ids/route.ts b/src/app/api/movies/[id]/external-ids/route.ts index aa66bd7..761dd89 100644 --- a/src/app/api/movies/[id]/external-ids/route.ts +++ b/src/app/api/movies/[id]/external-ids/route.ts @@ -1,45 +1,45 @@ -import { NextResponse } from 'next/server'; -import { headers } from 'next/headers'; - -export async function GET( - request: Request, - { params }: { params: { id: string } } -) { - try { - const headersList = headers(); - const response = await fetch( - `https://neomovies-api.vercel.app/movies/${params.id}/external-ids`, - { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - } - ); - - const data = await response.json(); - - // Создаем новый Response с нужными заголовками - return new NextResponse(JSON.stringify(data), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - } - }); - } catch (error) { - console.error('Error fetching external IDs:', error); - return new NextResponse( - JSON.stringify({ error: 'Failed to fetch external IDs' }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - } - ); - } -} +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const headersList = headers(); + const response = await fetch( + `https://neomovies-api.vercel.app/movies/${params.id}/external-ids`, + { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + ); + + const data = await response.json(); + + // Создаем новый Response с нужными заголовками + return new NextResponse(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); + } catch (error) { + console.error('Error fetching external IDs:', error); + return new NextResponse( + JSON.stringify({ error: 'Failed to fetch external IDs' }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + ); + } +} diff --git a/src/app/categories/[id]/page.tsx b/src/app/categories/[id]/page.tsx new file mode 100644 index 0000000..22c2e31 --- /dev/null +++ b/src/app/categories/[id]/page.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import styled from 'styled-components'; +import { categoriesAPI } from '@/lib/api'; +import MovieCard from '@/components/MovieCard'; +import { Movie, Category } from '@/lib/api'; + +// Styled Components +const Container = styled.div` + max-width: 1280px; + margin: 0 auto; + padding: 2rem 1rem; +`; + +const Title = styled.h1` + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 1.5rem; + color: #fff; +`; + +const ButtonsContainer = styled.div` + display: flex; + gap: 1rem; + margin-bottom: 2rem; + + @media (max-width: 640px) { + flex-direction: column; + gap: 0.5rem; + } +`; + +const TabButton = styled.button<{ $active?: boolean }>` + padding: 0.5rem 1.5rem; + border-radius: 0.375rem; + font-size: 0.875rem; + background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'}; + color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'}; + border: none; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'}; + } +`; + +const BackButton = styled.button` + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +`; + +const MediaGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1.5rem; + + @media (min-width: 640px) { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } + + @media (min-width: 1024px) { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } +`; + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + margin: 2rem 0; +`; + +const PaginationButton = styled.button<{ $active?: boolean }>` + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'}; + color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'}; + border: none; + cursor: pointer; + transition: all 0.2s; + min-width: 2.5rem; + + &:hover { + background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +`; + +const Spinner = styled.div` + width: 40px; + height: 40px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: #3182ce; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +`; + +const ErrorMessage = styled.div` + color: #fc8181; + text-align: center; + padding: 2rem; + background: rgba(252, 129, 129, 0.1); + border-radius: 0.5rem; + margin: 2rem 0; +`; + +type MediaType = 'movies' | 'tv'; + +function CategoryPage() { + // Используем хук useParams вместо props + const params = useParams(); + const categoryId = parseInt(params.id as string); + + const [category, setCategory] = useState(null); + const [mediaType, setMediaType] = useState('movies'); + const [movies, setMovies] = useState([]); + const [tvShows, setTvShows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [moviesAvailable, setMoviesAvailable] = useState(true); + const [tvShowsAvailable, setTvShowsAvailable] = useState(true); + + // Загрузка информации о категории + useEffect(() => { + async function fetchCategory() { + try { + const response = await categoriesAPI.getCategory(categoryId); + setCategory(response.data); + } catch (error) { + console.error('Error fetching category:', error); + setError('Не удалось загрузить информацию о категории'); + } + } + + if (categoryId) { + fetchCategory(); + } + }, [categoryId]); + + // Загрузка фильмов по категории + useEffect(() => { + async function fetchMovies() { + if (!categoryId) return; + + setLoading(true); + setError(null); + + try { + const response = await categoriesAPI.getMoviesByCategory(categoryId, page); + + if (response.data.results) { + // Добавляем дебаг-логи + console.log(`Получены фильмы для категории ${categoryId}, страница ${page}:`, { + count: response.data.results.length, + ids: response.data.results.slice(0, 5).map(m => m.id), + titles: response.data.results.slice(0, 5).map(m => m.title) + }); + + // Проверяем, есть ли фильмы в этой категории + const hasMovies = response.data.results.length > 0; + setMoviesAvailable(hasMovies); + + // Если фильмов нет, а выбран тип "movies", пробуем переключиться на сериалы + if (!hasMovies && mediaType === 'movies' && tvShowsAvailable) { + setMediaType('tv'); + } else { + setMovies(response.data.results); + + // Устанавливаем общее количество страниц + if (response.data.total_pages) { + setTotalPages(response.data.total_pages); + } + } + } else { + setMoviesAvailable(false); + if (mediaType === 'movies' && tvShowsAvailable) { + setMediaType('tv'); + } else { + setError('Не удалось загрузить фильмы'); + } + } + } catch (error) { + console.error('Error fetching movies:', error); + setMoviesAvailable(false); + if (mediaType === 'movies' && tvShowsAvailable) { + setMediaType('tv'); + } else { + setError('Ошибка при загрузке фильмов'); + } + } finally { + setLoading(false); + } + } + + if (mediaType === 'movies') { + fetchMovies(); + } + }, [categoryId, mediaType, page, tvShowsAvailable]); + + // Загрузка сериалов по категории + useEffect(() => { + async function fetchTVShows() { + if (!categoryId) return; + + setLoading(true); + setError(null); + + try { + const response = await categoriesAPI.getTVShowsByCategory(categoryId, page); + + if (response.data.results) { + // Добавляем дебаг-логи + console.log(`Получены сериалы для категории ${categoryId}, страница ${page}:`, { + count: response.data.results.length, + ids: response.data.results.slice(0, 5).map(tv => tv.id), + names: response.data.results.slice(0, 5).map(tv => tv.name) + }); + + // Проверяем, есть ли сериалы в этой категории + const hasTVShows = response.data.results.length > 0; + setTvShowsAvailable(hasTVShows); + + // Если сериалов нет, а выбран тип "tv", пробуем переключиться на фильмы + if (!hasTVShows && mediaType === 'tv' && moviesAvailable) { + setMediaType('movies'); + } else { + setTvShows(response.data.results); + + // Устанавливаем общее количество страниц + if (response.data.total_pages) { + setTotalPages(response.data.total_pages); + } + } + } else { + setTvShowsAvailable(false); + if (mediaType === 'tv' && moviesAvailable) { + setMediaType('movies'); + } else { + setError('Не удалось загрузить сериалы'); + } + } + } catch (error) { + console.error('Error fetching TV shows:', error); + setTvShowsAvailable(false); + if (mediaType === 'tv' && moviesAvailable) { + setMediaType('movies'); + } else { + setError('Ошибка при загрузке сериалов'); + } + } finally { + setLoading(false); + } + } + + if (mediaType === 'tv') { + fetchTVShows(); + } + }, [categoryId, mediaType, page, moviesAvailable]); + + function handleGoBack() { + window.history.back(); + } + + // Функции для пагинации + function handlePageChange(newPage: number) { + if (newPage >= 1 && newPage <= totalPages) { + setPage(newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } + + function renderPagination() { + if (totalPages <= 1) return null; + + const pageButtons = []; + // Отображаем максимум 5 страниц вокруг текущей + const startPage = Math.max(1, page - 2); + const endPage = Math.min(totalPages, startPage + 4); + + // Кнопка "Предыдущая" + pageButtons.push( + handlePageChange(page - 1)} + disabled={page === 1} + > + < + + ); + + // Отображаем первую страницу и многоточие, если startPage > 1 + if (startPage > 1) { + pageButtons.push( + handlePageChange(1)} + $active={page === 1} + > + 1 + + ); + + if (startPage > 2) { + pageButtons.push( + ... + ); + } + } + + // Отображаем страницы вокруг текущей + for (let i = startPage; i <= endPage; i++) { + pageButtons.push( + handlePageChange(i)} + $active={page === i} + > + {i} + + ); + } + + // Отображаем многоточие и последнюю страницу, если endPage < totalPages + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pageButtons.push( + ... + ); + } + + pageButtons.push( + handlePageChange(totalPages)} + $active={page === totalPages} + > + {totalPages} + + ); + } + + // Кнопка "Следующая" + pageButtons.push( + handlePageChange(page + 1)} + disabled={page === totalPages} + > + > + + ); + + return {pageButtons}; + } + + if (error) { + return ( + + + Назад к категориям + + {error} + + ); + } + + return ( + + + Назад к категориям + + + {category?.name || 'Загрузка...'} + + + { + if (moviesAvailable) { + setMediaType('movies'); + setPage(1); // Сбрасываем страницу при переключении типа контента + } + }} + disabled={!moviesAvailable} + style={{ opacity: moviesAvailable ? 1 : 0.5, cursor: moviesAvailable ? 'pointer' : 'not-allowed' }} + > + Фильмы + + { + if (tvShowsAvailable) { + setMediaType('tv'); + setPage(1); // Сбрасываем страницу при переключении типа контента + } + }} + disabled={!tvShowsAvailable} + style={{ opacity: tvShowsAvailable ? 1 : 0.5, cursor: tvShowsAvailable ? 'pointer' : 'not-allowed' }} + > + Сериалы + + + + {loading ? ( + + + + ) : ( + <> + + {mediaType === 'movies' ? ( + movies.length > 0 ? ( + movies.map(movie => ( + + )) + ) : ( +
+ Нет фильмов в этой категории +
+ ) + ) : ( + tvShows.length > 0 ? ( + tvShows.map(tvShow => ( + + )) + ) : ( +
+ Нет сериалов в этой категории +
+ ) + )} +
+ + {/* Отображаем пагинацию */} + {renderPagination()} + + )} +
+ ); +} + +export default CategoryPage; diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index d6f6de2..3d0e82c 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -2,14 +2,9 @@ import { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { moviesAPI } from '@/lib/api'; -import MovieCard from '@/components/MovieCard'; -import { Movie } from '@/lib/api'; - -interface Genre { - id: number; - name: string; -} +import { categoriesAPI } from '@/lib/api'; +import { Category } from '@/lib/api'; +import CategoryCard from '@/components/CategoryCard'; // Styled Components const Container = styled.div` @@ -18,50 +13,36 @@ const Container = styled.div` 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: 1.5rem; + font-size: 2rem; font-weight: bold; margin-bottom: 1.5rem; color: #fff; `; -const GenreButtons = styled.div` - display: flex; - gap: 0.5rem; +const Subtitle = styled.p` + color: rgba(255, 255, 255, 0.7); + font-size: 1rem; + margin-top: -0.5rem; margin-bottom: 2rem; - flex-wrap: wrap; -`; - -const GenreButton = 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; - - &:hover { - background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'}; - } -`; - -const MovieGrid = styled.div` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1.5rem; - - @media (min-width: 640px) { - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - } `; const LoadingContainer = styled.div` display: flex; justify-content: center; align-items: center; - min-height: 200px; + min-height: 300px; `; const Spinner = styled.div` @@ -88,71 +69,90 @@ const ErrorMessage = styled.div` margin: 2rem 0; `; -export default function CategoriesPage() { - const [genres, setGenres] = useState([]); - const [selectedGenre, setSelectedGenre] = useState(null); - const [movies, setMovies] = useState([]); +interface CategoryWithBackground extends Category { + backgroundUrl?: string; +} + +function CategoriesPage() { + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Загрузка жанров при монтировании + // Загрузка категорий и фоновых изображений для них useEffect(() => { - const fetchGenres = async () => { + async function fetchCategoriesAndBackgrounds() { setError(null); - try { - console.log('Fetching genres...'); - const response = await moviesAPI.getGenres(); - console.log('Genres response:', response.data); - - if (response.data.genres && response.data.genres.length > 0) { - setGenres(response.data.genres); - setSelectedGenre(response.data.genres[0].id); - } else { - setError('Не удалось загрузить жанры'); - } - } catch (error) { - console.error('Error fetching genres:', error); - setError('Ошибка при загрузке жанров'); - } - }; - fetchGenres(); - }, []); - - // Загрузка фильмов при изменении выбранного жанра - useEffect(() => { - const fetchMoviesByGenre = async () => { - if (!selectedGenre) return; - setLoading(true); - setError(null); + try { - console.log('Fetching movies for genre:', selectedGenre); - const response = await moviesAPI.getMoviesByGenre(selectedGenre); - console.log('Movies response:', { - total: response.data.results?.length, - first: response.data.results?.[0] - }); + // Получаем список категорий + const categoriesResponse = await categoriesAPI.getCategories(); - if (response.data.results) { - setMovies(response.data.results); - } else { - setError('Не удалось загрузить фильмы'); + if (!categoriesResponse.data.categories || categoriesResponse.data.categories.length === 0) { + setError('Не удалось загрузить категории'); + setLoading(false); + return; } + + // Добавляем фоновые изображения для каждой категории + const categoriesWithBackgrounds: CategoryWithBackground[] = await Promise.all( + categoriesResponse.data.categories.map(async (category: Category) => { + try { + // Сначала пробуем получить фильм для фона + const moviesResponse = await categoriesAPI.getMoviesByCategory(category.id, 1); + + // Проверяем, есть ли фильмы в данной категории + if (moviesResponse.data.results && moviesResponse.data.results.length > 0) { + const backgroundUrl = moviesResponse.data.results[0].backdrop_path || + moviesResponse.data.results[0].poster_path; + + return { + ...category, + backgroundUrl + }; + } else { + // Если фильмов нет, пробуем получить сериалы + const tvResponse = await categoriesAPI.getTVShowsByCategory(category.id, 1); + + if (tvResponse.data.results && tvResponse.data.results.length > 0) { + const backgroundUrl = tvResponse.data.results[0].backdrop_path || + tvResponse.data.results[0].poster_path; + + return { + ...category, + backgroundUrl + }; + } + } + + // Если ни фильмов, ни сериалов не найдено + return { + ...category, + backgroundUrl: undefined + }; + } catch (error) { + console.error(`Error fetching background for category ${category.id}:`, error); + return category; // Возвращаем категорию без фона в случае ошибки + } + }) + ); + + setCategories(categoriesWithBackgrounds); } catch (error) { - console.error('Error fetching movies:', error); - setError('Ошибка при загрузке фильмов'); + console.error('Error fetching categories:', error); + setError('Ошибка при загрузке категорий'); } finally { setLoading(false); } - }; - - fetchMoviesByGenre(); - }, [selectedGenre]); + } + + fetchCategoriesAndBackgrounds(); + }, []); if (error) { return ( - Категории фильмов + Категории {error} ); @@ -160,33 +160,26 @@ export default function CategoriesPage() { return ( - Категории фильмов - - {/* Кнопки жанров */} - - {genres.map((genre) => ( - setSelectedGenre(genre.id)} - > - {genre.name} - - ))} - - - {/* Сетка фильмов */} + Категории + Различные жанры фильмов и сериалов + {loading ? ( ) : ( - - {movies.map((movie) => ( - + + {categories.map((category) => ( + ))} - + )} ); } + +export default CategoriesPage; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a308475..e69de29 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,129 +0,0 @@ -'use client'; - -import dynamic from 'next/dynamic'; -import styled from 'styled-components'; - -const LoginClient = dynamic(() => import('./LoginClient'), { - ssr: false -}); - -export default function LoginPage() { - return ( - - - - - - - - - - Neo Movies - - - - - - - - ); -} - -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; -`; diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx index 69c3b86..c79693d 100644 --- a/src/app/movie/[id]/MovieContent.tsx +++ b/src/app/movie/[id]/MovieContent.tsx @@ -26,6 +26,10 @@ const Content = styled.div` width: 100%; max-width: 1200px; margin: 0 auto; + + @media (max-width: 768px) { + padding-top: 1rem; + } `; const MovieInfo = styled.div` @@ -35,11 +39,18 @@ const MovieInfo = styled.div` @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` @@ -48,14 +59,23 @@ const Poster = styled.img` box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); @media (max-width: 768px) { - width: 100%; - max-width: 300px; - margin: 0 auto; + 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` @@ -63,6 +83,16 @@ const Title = styled.h1` 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` @@ -70,11 +100,23 @@ const Info = styled.div` 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` @@ -82,6 +124,10 @@ const GenreList = styled.div` gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; + + @media (max-width: 768px) { + justify-content: center; + } `; const Genre = styled.span` @@ -90,21 +136,80 @@ const Genre = styled.span` 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` @@ -157,6 +262,7 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp @@ -174,19 +280,30 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp {movie.tagline && {movie.tagline}} {movie.overview} -
+ + + {imdbId && ( + document.getElementById('movie-player')?.scrollIntoView({ behavior: 'smooth' })} + > + + + + Смотреть + + )} -
+ {imdbId && ( - + diff --git a/src/app/movie/[id]/page.tsx b/src/app/movie/[id]/page.tsx index c27a0e0..86ac563 100644 --- a/src/app/movie/[id]/page.tsx +++ b/src/app/movie/[id]/page.tsx @@ -9,15 +9,25 @@ interface PageProps { } // Генерация метаданных для страницы -export async function generateMetadata({ params }: PageProps): Promise { - const { id } = params; +export async function generateMetadata( + props: { params: { id: string }} +): Promise { + // В Next.js 14, нужно сначала получить данные фильма, + // а затем использовать их для метаданных try { - const { data: movie } = await moviesAPI.getMovie(id); + // Получаем id для использования в запросе + const movieId = props.params.id; + + // Запрашиваем данные фильма + const { data: movie } = await moviesAPI.getMovie(movieId); + + // Создаем метаданные на основе полученных данных return { title: `${movie.title} - NeoMovies`, description: movie.overview, }; } catch (error) { + console.error('Error generating metadata:', error); return { title: 'Фильм - NeoMovies', }; diff --git a/src/app/tv/[id]/page.tsx b/src/app/tv/[id]/page.tsx index 7001610..0f3f442 100644 --- a/src/app/tv/[id]/page.tsx +++ b/src/app/tv/[id]/page.tsx @@ -19,7 +19,13 @@ async function getData(id: string) { } export default async function Page(props: PageProps) { - const { id } = props.params; - const data = await getData(id); - return ; + // В Next.js 14 нужно сначала использовать параметры в асинхронной функции + try { + const tvShowId = props.params.id; + const data = await getData(tvShowId); + return ; + } catch (error) { + console.error('Error loading TV show page:', error); + return
Ошибка загрузки страницы сериала
; + } } diff --git a/src/components/CategoryCard.tsx b/src/components/CategoryCard.tsx new file mode 100644 index 0000000..7882e84 --- /dev/null +++ b/src/components/CategoryCard.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import styled from 'styled-components'; +import { Category } from '@/lib/api'; + +interface CategoryCardProps { + category: Category; + backgroundUrl?: string; +} + +// Словарь цветов для разных жанров +const genreColors: Record = { + 28: '#E53935', // Боевик - красный + 12: '#43A047', // Приключения - зеленый + 16: '#FB8C00', // Мультфильм - оранжевый + 35: '#FFEE58', // Комедия - желтый + 80: '#424242', // Криминал - темно-серый + 99: '#8D6E63', // Документальный - коричневый + 18: '#5E35B1', // Драма - пурпурный + 10751: '#EC407A', // Семейный - розовый + 14: '#7E57C2', // Фэнтези - фиолетовый + 36: '#795548', // История - коричневый + 27: '#212121', // Ужасы - черный + 10402: '#26A69A', // Музыка - бирюзовый + 9648: '#5C6BC0', // Детектив - индиго + 10749: '#EC407A', // Мелодрама - розовый + 878: '#00BCD4', // Фантастика - голубой + 10770: '#9E9E9E', // ТВ фильм - серый + 53: '#FFA000', // Триллер - янтарный + 10752: '#455A64', // Военный - сине-серый + 37: '#8D6E63', // Вестерн - коричневый + // Добавим цвета для популярных жанров сериалов + 10759: '#1E88E5', // Боевик и приключения - синий + 10762: '#00ACC1', // Детский - циан + 10763: '#546E7A', // Новости - сине-серый + 10764: '#F06292', // Реалити-шоу - розовый + 10765: '#00BCD4', // Фантастика и фэнтези - голубой + 10766: '#5E35B1', // Мыльная опера - пурпурный + 10767: '#4CAF50', // Ток-шоу - зеленый + 10768: '#FFD54F' // Война и политика - желтый +}; + +// Получаем цвет для категории или используем запасной вариант +function getCategoryColor(categoryId: number): string { + 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) { + const router = useRouter(); + const [imageUrl, setImageUrl] = useState(backgroundUrl || '/images/placeholder.jpg'); + + const categoryColor = getCategoryColor(category.id); + + function handleClick() { + router.push(`/categories/${category.id}`); + } + + return ( + + + {category.name} + Фильмы и сериалы + + + ); +} + +export default CategoryCard; diff --git a/src/components/GlassCard.tsx b/src/components/GlassCard.tsx index 4a62be3..2f2281b 100644 --- a/src/components/GlassCard.tsx +++ b/src/components/GlassCard.tsx @@ -1,17 +1,33 @@ import styled from 'styled-components'; export const GlassCard = styled.div` - background: rgba(0, 0, 0, 0.45); - backdrop-filter: blur(20px); + background: rgba(0, 0, 0, 0.65); /* Увеличили непрозрачность фона для лучшей читаемости */ + /* Убираем тяжелый blur на мобильных устройствах */ + @supports (backdrop-filter: blur(20px)) { + backdrop-filter: blur(20px); + background: rgba(0, 0, 0, 0.45); + } border: 1px solid rgba(255, 255, 255, 0.1); - padding: 3rem; - border-radius: 24px; + padding: 2.5rem; + border-radius: 16px; /* Уменьшили радиус для компактности */ 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; + overflow: hidden; /* Предотвращаем выход контента за пределы карточки */ + + @media (max-width: 640px) { + padding: 1.75rem 1.5rem; + border-radius: 12px; + } + + @media (max-width: 480px) { + padding: 1.5rem 1.25rem; + margin: 0 0.5rem; + width: calc(100% - 1rem); + } `; export default GlassCard; diff --git a/src/components/MovieCard.tsx b/src/components/MovieCard.tsx index cc7a0af..88b02ad 100644 --- a/src/components/MovieCard.tsx +++ b/src/components/MovieCard.tsx @@ -4,65 +4,97 @@ import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; import styled from 'styled-components'; -import { Movie } from '@/types/movie'; +import { Movie, TVShow } from '@/types/movie'; import { formatDate } from '@/lib/utils'; import { useImageLoader } from '@/hooks/useImageLoader'; +// Тип-гард для проверки, является ли объект сериалом +function isTVShow(media: Movie | TVShow): media is TVShow { + return 'name' in media && 'first_air_date' in media; +} + interface MovieCardProps { - movie: Movie; + movie: Movie | TVShow; priority?: boolean; } export default function MovieCard({ movie, priority = false }: MovieCardProps) { - const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); + // Определяем, это фильм или сериал с помощью тип-гарда + const isTV = isTVShow(movie); + + // Используем правильный заголовок и дату в зависимости от типа + const title = isTV ? movie.name || 'Без названия' : movie.title || 'Без названия'; + const date = isTV ? movie.first_air_date : movie.release_date; + + // Выбираем правильный URL + const url = isTV ? `/tv/${movie.id}` : `/movie/${movie.id}`; + + // Загружаем изображение с оптимизированным размером для конкретного устройства + // Используем меньший размер изображения для мобильных устройств + const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); // Используем поддерживаемый размер return ( - + {isLoading ? ( -
-
-
+ +
+ ) : imageUrl ? ( ) : ( -
- No Image -
+ + + + + + )} {movie.vote_average.toFixed(1)} - {movie.title} - {formatDate(movie.release_date)} + {title} + {date ? formatDate(date) : 'Без даты'} ); } +// Функция для определения цвета рейтинга const getRatingColor = (rating: number) => { if (rating >= 7) return '#4CAF50'; if (rating >= 5) return '#FFC107'; return '#F44336'; }; +// Оптимизированные стилевые компоненты для мобильных устройств const Card = styled(Link)` position: relative; - border-radius: 16px; + border-radius: 12px; /* Уменьшили радиус для компактности */ overflow: hidden; - background: #242424; + 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` @@ -75,33 +107,85 @@ const Poster = styled(Image)` 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; + margin: 0 0 4px 0; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + 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.div` +const Year = styled.p` font-size: 12px; - color: #808191; - margin-top: 4px; + color: #aaa; + margin: 0; + + @media (max-width: 640px) { + font-size: 11px; /* Уменьшенный размер шрифта для мобильных устройств */ + } `; const Rating = styled.div` position: absolute; top: 8px; right: 8px; - padding: 4px 8px; - border-radius: 6px; - font-size: 17px; - font-weight: 600; + 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; + } `; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 3ee70de..43719b9 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -75,14 +75,15 @@ const MobileNav = styled.nav` top: 0; left: 0; right: 0; - background: rgba(18, 18, 23, 0.8); - backdrop-filter: blur(10px); + 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: 60px; + height: 56px; /* Уменьшили высоту для компактности */ @media (min-width: 769px) { display: none; @@ -115,17 +116,19 @@ const MobileMenuButton = styled.button` const MobileMenu = styled.div<{ $isOpen: boolean }>` position: fixed; - top: 60px; + top: 56px; /* Соответствует новой высоте навбара */ left: 0; right: 0; bottom: 0; - background: rgba(18, 18, 23, 0.95); - backdrop-filter: blur(10px); + background: #121217; /* Сплошной фон без прозрачности */ + /* Удалили тяжелый эффект blur */ transform: translateX(${props => props.$isOpen ? '0' : '100%'}); - transition: transform 0.3s ease-in-out; + 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; @@ -136,20 +139,27 @@ const MobileMenuItem = styled.div` display: flex; align-items: center; gap: 0.75rem; - padding: 1rem; + padding: 1rem 0.75rem; /* Уменьшили горизонтальные отступы */ + margin-bottom: 0.25rem; /* Добавили отступ между элементами */ color: white; text-decoration: none; - border-radius: 12px; - transition: background-color 0.2s; + border-radius: 8px; /* Уменьшили радиус для компактности */ font-size: 1rem; + font-weight: 500; /* Добавили небольшое утолщение шрифта */ + position: relative; /* Для анимации ripple-эффекта */ + overflow: hidden; /* Для анимации ripple-эффекта */ - &:hover { - background: rgba(255, 255, 255, 0.1); + /* Заменили плавную анимацию на мгновенную для мобильных устройств */ + &:active { + background: rgba(255, 255, 255, 0.15); + transform: scale(0.98); /* Небольшой эффект нажатия */ } svg { - width: 20px; - height: 20px; + width: 22px; /* Увеличили иконки для лучшей видимости на мобильных устройствах */ + height: 22px; + min-width: 22px; /* Чтобы иконки были выровнены */ + color: #3b82f6; /* Цвет для лучшего визуального разделения */ } `; diff --git a/src/lib/api.ts b/src/lib/api.ts index 3b5bd92..8d812db 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,12 @@ export const api = axios.create({ } }); +export interface Category { + id: number; + name: string; + slug: string; +} + export interface Genre { id: number; name: string; @@ -117,6 +123,32 @@ export interface TVShowResponse { total_results: number; } +export const categoriesAPI = { + // Получение всех категорий + getCategories() { + return api.get<{ categories: Category[] }>('/categories'); + }, + + // Получение категории по ID + getCategory(id: number) { + return api.get(`/categories/${id}`); + }, + + // Получение фильмов по категории + getMoviesByCategory(categoryId: number, page = 1) { + return api.get(`/categories/${categoryId}/movies`, { + params: { page } + }); + }, + + // Получение сериалов по категории + getTVShowsByCategory(categoryId: number, page = 1) { + return api.get(`/categories/${categoryId}/tv`, { + params: { page } + }); + } +}; + export const moviesAPI = { // Получение популярных фильмов getPopular(page = 1) { @@ -163,9 +195,14 @@ export const moviesAPI = { // Получение фильмов по жанру getMoviesByGenre(genreId: number, page = 1) { - return api.get('/movies/genre/' + genreId, { - params: { page } + return api.get('/movies/discover', { + params: { with_genres: genreId, page } }); + }, + + // Получение жанров + getGenres() { + return api.get<{ genres: Genre[] }>('/movies/genres'); } };