mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-27 17:38:50 +05:00
full change ui and small fixes
This commit is contained in:
@@ -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
9281
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
<
|
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">
|
||||||
);
|
<
|
||||||
|
</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}
|
|
||||||
>
|
|
||||||
>
|
>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
`;
|
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
225
src/app/page.tsx
225
src/app/page.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
61
src/app/search/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api';
|
||||||
|
import MovieCard from '@/components/MovieCard';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||||
|
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentQuery = searchParams.get('q');
|
||||||
|
if (currentQuery) {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
moviesAPI.searchMovies(currentQuery),
|
||||||
|
tvAPI.searchShows(currentQuery)
|
||||||
|
]).then(([movieResults, tvResults]) => {
|
||||||
|
const combined = [...(movieResults.data.results || []), ...(tvResults.data.results || [])];
|
||||||
|
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setResults([]);
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground w-full px- sm:px lg:px-8 py-8">
|
||||||
|
{searchParams.get('q') && (
|
||||||
|
<h1 className="text-2xl font-bold mb-8">
|
||||||
|
Результаты поиска для: <span className="text-primary">"{searchParams.get('q')}"</span>
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center">Загрузка...</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
|
{results.map(item => (
|
||||||
|
<MovieCard key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`} movie={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
searchParams.get('q') && <div className="text-center">Ничего не найдено.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/app/tv/[id]/TVContent.tsx
Normal file
173
src/app/tv/[id]/TVContent.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { tvAPI } from '@/lib/api';
|
||||||
|
import { getImageUrl } from '@/lib/neoApi';
|
||||||
|
import type { TVShowDetails } from '@/lib/api';
|
||||||
|
import MoviePlayer from '@/components/MoviePlayer';
|
||||||
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TVContentProps {
|
||||||
|
showId: string;
|
||||||
|
initialShow: TVShowDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||||
|
const [show] = useState<TVShowDetails>(initialShow);
|
||||||
|
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||||
|
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||||
|
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||||
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchImdbId = async () => {
|
||||||
|
try {
|
||||||
|
// Используем dedicated эндпоинт для получения IMDb ID
|
||||||
|
const { data } = await tvAPI.getImdbId(showId);
|
||||||
|
if (data?.imdb_id) {
|
||||||
|
setImdbId(data.imdb_id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching IMDb ID:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
|
||||||
|
if (initialShow.external_ids?.imdb_id) {
|
||||||
|
setImdbId(initialShow.external_ids.imdb_id);
|
||||||
|
} else {
|
||||||
|
fetchImdbId();
|
||||||
|
}
|
||||||
|
}, [showId, initialShow.external_ids]);
|
||||||
|
|
||||||
|
const showControls = () => {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setIsControlsVisible(true);
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsControlsVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenPlayer = () => {
|
||||||
|
setIsPlayerFullscreen(true);
|
||||||
|
showControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClosePlayer = () => {
|
||||||
|
setIsPlayerFullscreen(false);
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||||
|
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(show.poster_path, 'w500')}
|
||||||
|
alt={`Постер сериала ${show.name}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
{show.name}
|
||||||
|
</h1>
|
||||||
|
{show.tagline && (
|
||||||
|
<p className="mt-1 text-lg text-muted-foreground">{show.tagline}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span className="font-medium">Рейтинг: {show.vote_average.toFixed(1)}</span>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
<span className="text-muted-foreground">Сезонов: {show.number_of_seasons}</span>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
<span className="text-muted-foreground">{formatDate(show.first_air_date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{show.genres.map((genre) => (
|
||||||
|
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||||
|
{genre.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||||
|
<p>{show.overview}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center gap-4">
|
||||||
|
{imdbId && (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenPlayer}
|
||||||
|
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<PlayCircle size={20} />
|
||||||
|
<span>Смотреть</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<FavoriteButton
|
||||||
|
mediaId={show.id.toString()}
|
||||||
|
mediaType="tv"
|
||||||
|
title={show.name}
|
||||||
|
posterPath={show.poster_path}
|
||||||
|
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imdbId && (
|
||||||
|
<div id="movie-player" className="mt-10 hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||||
|
<MoviePlayer
|
||||||
|
id={show.id.toString()}
|
||||||
|
title={show.name}
|
||||||
|
poster={show.poster_path || ''}
|
||||||
|
imdbId={imdbId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPlayerFullscreen && imdbId && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
|
||||||
|
onMouseMove={showControls}
|
||||||
|
onClick={showControls}
|
||||||
|
>
|
||||||
|
<MoviePlayer
|
||||||
|
id={show.id.toString()}
|
||||||
|
title={show.name}
|
||||||
|
poster={show.poster_path || ''}
|
||||||
|
imdbId={imdbId}
|
||||||
|
isFullscreen={true}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleClosePlayer}
|
||||||
|
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
aria-label="Назад"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/tv/[id]/TVPage.tsx
Normal file
30
src/app/tv/[id]/TVPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import TVContent from '@/app/tv/[id]/TVContent';
|
||||||
|
import type { TVShowDetails } from '@/lib/api';
|
||||||
|
|
||||||
|
interface TVPageProps {
|
||||||
|
showId: string;
|
||||||
|
show: TVShowDetails | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TVPage({ showId, show }: TVPageProps) {
|
||||||
|
if (!show) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="w-full min-h-screen">
|
||||||
|
<div>Сериал не найден</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="w-full">
|
||||||
|
<TVContent showId={showId} initialShow={show} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,53 +1,46 @@
|
|||||||
import { Metadata } from 'next';
|
import { 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>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/components/HeaderBar.tsx
Normal file
118
src/components/HeaderBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/HorizontalSlider.tsx
Normal file
38
src/components/HorizontalSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/MobileNav.tsx
Normal file
38
src/components/MobileNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/components/MovieTile.tsx
Normal file
53
src/components/MovieTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
src/components/ThemeProvider.tsx
Normal file
8
src/components/ThemeProvider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
23
src/middleware.ts
Normal 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
47
src/styles/theme.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user