mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 01:48:50 +05:00
full change ui and small fixes
This commit is contained in:
@@ -1,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",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/classnames": "^2.3.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/styled-components": "^5.1.34",
|
||||
@@ -20,6 +21,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"classnames": "^2.5.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -27,13 +29,15 @@
|
||||
"mongodb": "^6.12.0",
|
||||
"mongoose": "^8.9.2",
|
||||
"next": "15.1.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"resend": "^4.0.1",
|
||||
"styled-components": "^6.1.13"
|
||||
"styled-components": "^6.1.13",
|
||||
"tailwind": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -1,155 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { categoriesAPI } from '@/lib/api';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { categoriesAPI, Movie, TVShow } from '@/lib/api';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import { Movie, Category } from '@/lib/api';
|
||||
|
||||
// Styled Components
|
||||
const Container = styled.div`
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const TabButton = styled.button<{ $active?: boolean }>`
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'};
|
||||
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const BackButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
const MediaGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1.5rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const PaginationContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
`;
|
||||
|
||||
const PaginationButton = styled.button<{ $active?: boolean }>`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: ${props => props.$active ? '#3182ce' : 'rgba(255, 255, 255, 0.1)'};
|
||||
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.8)'};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 2.5rem;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.$active ? '#2b6cb0' : 'rgba(255, 255, 255, 0.2)'};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
`;
|
||||
|
||||
const Spinner = styled.div`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-left-color: #3182ce;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #fc8181;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: rgba(252, 129, 129, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
`;
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
type MediaType = 'movies' | 'tv';
|
||||
|
||||
function CategoryPage() {
|
||||
// Используем хук useParams вместо props
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const categoryId = parseInt(params.id as string);
|
||||
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [categoryName, setCategoryName] = useState<string>('');
|
||||
const [mediaType, setMediaType] = useState<MediaType>('movies');
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [tvShows, setTvShows] = useState<Movie[]>([]);
|
||||
const [items, setItems] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -157,328 +23,211 @@ function CategoryPage() {
|
||||
const [moviesAvailable, setMoviesAvailable] = useState(true);
|
||||
const [tvShowsAvailable, setTvShowsAvailable] = useState(true);
|
||||
|
||||
// Загрузка информации о категории
|
||||
useEffect(() => {
|
||||
async function fetchCategory() {
|
||||
async function fetchCategoryName() {
|
||||
try {
|
||||
const response = await categoriesAPI.getCategory(categoryId);
|
||||
setCategory(response.data);
|
||||
setCategoryName(response.data.name);
|
||||
} catch (error) {
|
||||
console.error('Error fetching category:', error);
|
||||
setError('Не удалось загрузить информацию о категории');
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
fetchCategory();
|
||||
fetchCategoryName();
|
||||
}
|
||||
}, [categoryId]);
|
||||
|
||||
// Загрузка фильмов по категории
|
||||
useEffect(() => {
|
||||
async function fetchMovies() {
|
||||
async function fetchData() {
|
||||
if (!categoryId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await categoriesAPI.getMoviesByCategory(categoryId, page);
|
||||
|
||||
if (response.data.results) {
|
||||
// Добавляем дебаг-логи
|
||||
console.log(`Получены фильмы для категории ${categoryId}, страница ${page}:`, {
|
||||
count: response.data.results.length,
|
||||
ids: response.data.results.slice(0, 5).map(m => m.id),
|
||||
titles: response.data.results.slice(0, 5).map(m => m.title)
|
||||
});
|
||||
|
||||
// Проверяем, есть ли фильмы в этой категории
|
||||
const hasMovies = response.data.results.length > 0;
|
||||
setMoviesAvailable(hasMovies);
|
||||
|
||||
// Если фильмов нет, а выбран тип "movies", пробуем переключиться на сериалы
|
||||
if (!hasMovies && mediaType === 'movies' && tvShowsAvailable) {
|
||||
setMediaType('tv');
|
||||
} else {
|
||||
setMovies(response.data.results);
|
||||
|
||||
// Устанавливаем общее количество страниц
|
||||
if (response.data.total_pages) {
|
||||
setTotalPages(response.data.total_pages);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMoviesAvailable(false);
|
||||
if (mediaType === 'movies' && tvShowsAvailable) {
|
||||
setMediaType('tv');
|
||||
} else {
|
||||
setError('Не удалось загрузить фильмы');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching movies:', error);
|
||||
setMoviesAvailable(false);
|
||||
if (mediaType === 'movies' && tvShowsAvailable) {
|
||||
setMediaType('tv');
|
||||
} else {
|
||||
setError('Ошибка при загрузке фильмов');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
let response;
|
||||
if (mediaType === 'movies') {
|
||||
fetchMovies();
|
||||
}
|
||||
}, [categoryId, mediaType, page, tvShowsAvailable]);
|
||||
|
||||
// Загрузка сериалов по категории
|
||||
useEffect(() => {
|
||||
async function fetchTVShows() {
|
||||
if (!categoryId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
||||
|
||||
if (response.data.results) {
|
||||
// Добавляем дебаг-логи
|
||||
console.log(`Получены сериалы для категории ${categoryId}, страница ${page}:`, {
|
||||
count: response.data.results.length,
|
||||
ids: response.data.results.slice(0, 5).map(tv => tv.id),
|
||||
names: response.data.results.slice(0, 5).map(tv => tv.name)
|
||||
});
|
||||
|
||||
// Проверяем, есть ли сериалы в этой категории
|
||||
const hasTVShows = response.data.results.length > 0;
|
||||
setTvShowsAvailable(hasTVShows);
|
||||
|
||||
// Если сериалов нет, а выбран тип "tv", пробуем переключиться на фильмы
|
||||
if (!hasTVShows && mediaType === 'tv' && moviesAvailable) {
|
||||
setMediaType('movies');
|
||||
} else {
|
||||
setTvShows(response.data.results);
|
||||
|
||||
// Устанавливаем общее количество страниц
|
||||
if (response.data.total_pages) {
|
||||
response = await categoriesAPI.getMoviesByCategory(categoryId, page);
|
||||
const hasMovies = response.data.results.length > 0;
|
||||
if (page === 1) setMoviesAvailable(hasMovies);
|
||||
setItems(response.data.results);
|
||||
setTotalPages(response.data.total_pages);
|
||||
}
|
||||
if (!hasMovies && tvShowsAvailable && page === 1) {
|
||||
setMediaType('tv');
|
||||
}
|
||||
} else {
|
||||
setTvShowsAvailable(false);
|
||||
if (mediaType === 'tv' && moviesAvailable) {
|
||||
response = await categoriesAPI.getTVShowsByCategory(categoryId, page);
|
||||
const hasTvShows = response.data.results.length > 0;
|
||||
if (page === 1) setTvShowsAvailable(hasTvShows);
|
||||
const transformedShows = response.data.results.map((show: TVShow) => ({
|
||||
...show,
|
||||
title: show.name,
|
||||
release_date: show.first_air_date,
|
||||
}));
|
||||
setItems(transformedShows);
|
||||
setTotalPages(response.data.total_pages);
|
||||
if (!hasTvShows && moviesAvailable && page === 1) {
|
||||
setMediaType('movies');
|
||||
} else {
|
||||
setError('Не удалось загрузить сериалы');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching TV shows:', error);
|
||||
setTvShowsAvailable(false);
|
||||
if (mediaType === 'tv' && moviesAvailable) {
|
||||
setMediaType('movies');
|
||||
} else {
|
||||
setError('Ошибка при загрузке сериалов');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка при загрузке данных');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [categoryId, mediaType, page, moviesAvailable, tvShowsAvailable]);
|
||||
|
||||
if (mediaType === 'tv') {
|
||||
fetchTVShows();
|
||||
}
|
||||
}, [categoryId, mediaType, page, moviesAvailable]);
|
||||
const handleMediaTypeChange = (type: MediaType) => {
|
||||
if (type === 'movies' && !moviesAvailable) return;
|
||||
if (type === 'tv' && !tvShowsAvailable) return;
|
||||
setMediaType(type);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
function handleGoBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Функции для пагинации
|
||||
function handlePageChange(newPage: number) {
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
setPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function renderPagination() {
|
||||
const Pagination = useMemo(() => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pageButtons = [];
|
||||
// Отображаем максимум 5 страниц вокруг текущей
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(totalPages, startPage + 4);
|
||||
const maxPagesToShow = 5;
|
||||
let startPage: number;
|
||||
let endPage: number;
|
||||
|
||||
// Кнопка "Предыдущая"
|
||||
if (totalPages <= maxPagesToShow) {
|
||||
startPage = 1;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
const maxPagesBeforeCurrent = Math.floor(maxPagesToShow / 2);
|
||||
const maxPagesAfterCurrent = Math.ceil(maxPagesToShow / 2) - 1;
|
||||
if (page <= maxPagesBeforeCurrent) {
|
||||
startPage = 1;
|
||||
endPage = maxPagesToShow;
|
||||
} else if (page + maxPagesAfterCurrent >= totalPages) {
|
||||
startPage = totalPages - maxPagesToShow + 1;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
startPage = page - maxPagesBeforeCurrent;
|
||||
endPage = page + maxPagesAfterCurrent;
|
||||
}
|
||||
}
|
||||
|
||||
// Previous button
|
||||
pageButtons.push(
|
||||
<PaginationButton
|
||||
key="prev"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<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">
|
||||
<
|
||||
</PaginationButton>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Отображаем первую страницу и многоточие, если startPage > 1
|
||||
if (startPage > 1) {
|
||||
pageButtons.push(
|
||||
<PaginationButton
|
||||
key="1"
|
||||
onClick={() => handlePageChange(1)}
|
||||
$active={page === 1}
|
||||
>
|
||||
1
|
||||
</PaginationButton>
|
||||
);
|
||||
|
||||
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" style={{ color: 'white' }}>...</span>
|
||||
);
|
||||
pageButtons.push(<span key="dots1" className="px-3 py-1">...</span>);
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем страницы вокруг текущей
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageButtons.push(
|
||||
<PaginationButton
|
||||
key={i}
|
||||
onClick={() => handlePageChange(i)}
|
||||
$active={page === i}
|
||||
>
|
||||
<button key={i} onClick={() => handlePageChange(i)} disabled={page === i} className={`px-3 py-1 rounded-md ${page === i ? 'bg-accent text-white' : 'bg-card hover:bg-card/80'}`}>
|
||||
{i}
|
||||
</PaginationButton>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Отображаем многоточие и последнюю страницу, если endPage < totalPages
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
pageButtons.push(
|
||||
<span key="dots2" style={{ color: 'white' }}>...</span>
|
||||
);
|
||||
pageButtons.push(<span key="dots2" className="px-3 py-1">...</span>);
|
||||
}
|
||||
pageButtons.push(<button key={totalPages} onClick={() => handlePageChange(totalPages)} className="px-3 py-1 rounded-md bg-card hover:bg-card/80">{totalPages}</button>);
|
||||
}
|
||||
|
||||
// Next button
|
||||
pageButtons.push(
|
||||
<PaginationButton
|
||||
key={totalPages}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
$active={page === totalPages}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Кнопка "Следующая"
|
||||
pageButtons.push(
|
||||
<PaginationButton
|
||||
key="next"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<button key="next" onClick={() => handlePageChange(page + 1)} disabled={page === totalPages} className="px-3 py-1 rounded-md bg-card hover:bg-card/80 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
>
|
||||
</PaginationButton>
|
||||
</button>
|
||||
);
|
||||
|
||||
return <PaginationContainer>{pageButtons}</PaginationContainer>;
|
||||
}
|
||||
return <div className="flex justify-center items-center gap-2 my-8 text-foreground">{pageButtons}</div>;
|
||||
}, [page, totalPages]);
|
||||
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<BackButton onClick={handleGoBack}>
|
||||
<span>←</span> Назад к категориям
|
||||
</BackButton>
|
||||
<ErrorMessage>{error}</ErrorMessage>
|
||||
</Container>
|
||||
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 mb-4 px-4 py-2 rounded-md bg-card hover:bg-card/80 text-foreground">
|
||||
<ArrowLeft size={16} />
|
||||
Назад к категориям
|
||||
</button>
|
||||
<div className="text-red-500 text-center p-8 bg-red-500/10 rounded-lg my-8">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<BackButton onClick={handleGoBack}>
|
||||
<span>←</span> Назад к категориям
|
||||
</BackButton>
|
||||
<div className="min-h-screen bg-background text-foreground w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 mb-4 px-4 py-2 rounded-md bg-card hover:bg-card/80 text-foreground">
|
||||
<ArrowLeft size={16} />
|
||||
Назад к категориям
|
||||
</button>
|
||||
|
||||
<Title>{category?.name || 'Загрузка...'}</Title>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6 text-foreground">
|
||||
{categoryName || 'Загрузка...'}
|
||||
</h1>
|
||||
|
||||
<ButtonsContainer>
|
||||
<TabButton
|
||||
$active={mediaType === 'movies'}
|
||||
onClick={() => {
|
||||
if (moviesAvailable) {
|
||||
setMediaType('movies');
|
||||
setPage(1); // Сбрасываем страницу при переключении типа контента
|
||||
}
|
||||
}}
|
||||
disabled={!moviesAvailable}
|
||||
style={{ opacity: moviesAvailable ? 1 : 0.5, cursor: moviesAvailable ? 'pointer' : 'not-allowed' }}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => handleMediaTypeChange('movies')}
|
||||
disabled={!moviesAvailable || mediaType === 'movies'}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${mediaType === 'movies' ? 'bg-accent text-white' : 'bg-card hover:bg-card/80 text-foreground'}`}
|
||||
>
|
||||
Фильмы
|
||||
</TabButton>
|
||||
<TabButton
|
||||
$active={mediaType === 'tv'}
|
||||
onClick={() => {
|
||||
if (tvShowsAvailable) {
|
||||
setMediaType('tv');
|
||||
setPage(1); // Сбрасываем страницу при переключении типа контента
|
||||
}
|
||||
}}
|
||||
disabled={!tvShowsAvailable}
|
||||
style={{ opacity: tvShowsAvailable ? 1 : 0.5, cursor: tvShowsAvailable ? 'pointer' : 'not-allowed' }}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMediaTypeChange('tv')}
|
||||
disabled={!tvShowsAvailable || mediaType === 'tv'}
|
||||
className={`px-6 py-2 rounded-md text-sm font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${mediaType === 'tv' ? 'bg-accent text-white' : 'bg-card hover:bg-card/80 text-foreground'}`}
|
||||
>
|
||||
Сериалы
|
||||
</TabButton>
|
||||
</ButtonsContainer>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingContainer>
|
||||
<Spinner />
|
||||
</LoadingContainer>
|
||||
<div className="flex justify-center items-center min-h-[40vh]">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MediaGrid>
|
||||
{mediaType === 'movies' ? (
|
||||
movies.length > 0 ? (
|
||||
movies.map(movie => (
|
||||
{items.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(180px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6">
|
||||
{items.map(item => (
|
||||
<MovieCard
|
||||
key={`movie-${categoryId}-${movie.id}-${page}`}
|
||||
movie={movie}
|
||||
key={`${mediaType}-${item.id}-${page}`}
|
||||
movie={item}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '2rem' }}>
|
||||
Нет фильмов в этой категории
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
tvShows.length > 0 ? (
|
||||
tvShows.map(tvShow => (
|
||||
<MovieCard
|
||||
key={`tv-${categoryId}-${tvShow.id}-${page}`}
|
||||
movie={tvShow}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '2rem' }}>
|
||||
Нет сериалов в этой категории
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p>Нет {mediaType === 'movies' ? 'фильмов' : 'сериалов'} в этой категории.</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</MediaGrid>
|
||||
|
||||
{/* Отображаем пагинацию */}
|
||||
{renderPagination()}
|
||||
{Pagination}
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { categoriesAPI } from '@/lib/api';
|
||||
import { Category } from '@/lib/api';
|
||||
import CategoryCard from '@/components/CategoryCard';
|
||||
|
||||
// Styled Components
|
||||
const Container = styled.div`
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 1rem;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
`;
|
||||
|
||||
const Spinner = styled.div`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-left-color: #3182ce;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #fc8181;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: rgba(252, 129, 129, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
`;
|
||||
|
||||
interface CategoryWithBackground extends Category {
|
||||
backgroundUrl?: string;
|
||||
backgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
function CategoriesPage() {
|
||||
@@ -151,34 +87,44 @@ function CategoriesPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Категории</Title>
|
||||
<ErrorMessage>{error}</ErrorMessage>
|
||||
</Container>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">Категории</h1>
|
||||
<div className="text-red-500 text-center p-4 bg-red-50 dark:bg-red-900/50 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>Категории</Title>
|
||||
<Subtitle>Различные жанры фильмов и сериалов</Subtitle>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">Категории</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Выберите категорию для просмотра фильмов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingContainer>
|
||||
<Spinner />
|
||||
</LoadingContainer>
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Grid>
|
||||
{categories.map((category) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{categories.map((category, index) => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
key={index}
|
||||
category={category}
|
||||
backgroundUrl={category.backgroundUrl}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { favoritesAPI } from '@/lib/favoritesApi';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
`;
|
||||
|
||||
const Card = styled(Link)`
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Poster = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const Info = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
`;
|
||||
|
||||
const MediaTitle = styled.h2`
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MediaType = styled.span`
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4rem 0;
|
||||
`;
|
||||
import { Loader2, HeartCrack } from 'lucide-react';
|
||||
|
||||
interface Favorite {
|
||||
id: number;
|
||||
@@ -104,7 +34,6 @@ export default function FavoritesPage() {
|
||||
setFavorites(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch favorites:', error);
|
||||
// If token is invalid, clear it and redirect to login
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userName');
|
||||
localStorage.removeItem('userEmail');
|
||||
@@ -119,52 +48,73 @@ export default function FavoritesPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Избранное</Title>
|
||||
<EmptyState>Загрузка...</EmptyState>
|
||||
</Container>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="h-16 w-16 animate-spin text-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Избранное</Title>
|
||||
<EmptyState>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<HeartCrack size={80} className="mx-auto mb-6 text-gray-400" />
|
||||
<h1 className="text-3xl font-bold text-foreground mb-4">Избранное пусто</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
У вас пока нет избранных фильмов и сериалов
|
||||
</EmptyState>
|
||||
</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 (
|
||||
<Container>
|
||||
<Title>Избранное</Title>
|
||||
<Grid>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">Избранное</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Ваша коллекция любимых фильмов и сериалов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{favorites.map(favorite => (
|
||||
<Card
|
||||
<Link
|
||||
key={`${favorite.mediaType}-${favorite.mediaId}`}
|
||||
href={`/${favorite.mediaType === 'movie' ? 'movie' : 'tv'}/${favorite.mediaId}`}
|
||||
className="group"
|
||||
>
|
||||
<Poster>
|
||||
<div className="overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 shadow-sm transition-all duration-300 group-hover:shadow-lg group-hover:-translate-y-1">
|
||||
<div className="relative aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={favorite.posterPath ? getImageUrl(favorite.posterPath) : '/images/placeholder.jpg'}
|
||||
alt={favorite.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</Poster>
|
||||
<Info>
|
||||
<MediaTitle>{favorite.title}</MediaTitle>
|
||||
<MediaType>{favorite.mediaType === 'movie' ? 'Фильм' : 'Сериал'}</MediaType>
|
||||
</Info>
|
||||
</Card>
|
||||
</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>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ClientLayout } from '@/components/ClientLayout';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import type { Metadata } from 'next';
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { TermsChecker } from './providers/terms-check';
|
||||
@@ -23,7 +24,9 @@ export default function RootLayout({
|
||||
<meta name="darkreader-lock" />
|
||||
</head>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<Providers>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
</Providers>
|
||||
<TermsChecker />
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
@@ -1,170 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1rem;
|
||||
`;
|
||||
|
||||
const InputGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2196f3;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #2196f3, #1e88e5);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 1rem;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right, #1e88e5, #1976d2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const DividerText = styled.span`
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.875rem;
|
||||
padding: 0 1rem;
|
||||
`;
|
||||
|
||||
const GoogleButton = styled(Button)`
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const ToggleText = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
|
||||
button {
|
||||
color: #2196f3;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #ff5252;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 82, 82, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
`;
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function LoginClient() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
@@ -172,10 +11,10 @@ export default function LoginClient() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { login, register } = useAuth();
|
||||
|
||||
// Redirect authenticated users away from /login
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && localStorage.getItem('token')) {
|
||||
router.replace('/');
|
||||
@@ -185,114 +24,105 @@ export default function LoginClient() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await register(email, password, name);
|
||||
// Сохраняем пароль для автовхода после верификации
|
||||
localStorage.setItem('password', password);
|
||||
router.push(`/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
signIn('google', { callbackUrl: '/' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
<Title>{isLogin ? 'С возвращением!' : 'Создать аккаунт'}</Title>
|
||||
<Subtitle>
|
||||
{isLogin
|
||||
? 'Войдите в свой аккаунт для доступа к фильмам'
|
||||
: 'Зарегистрируйтесь для доступа ко всем возможностям'}
|
||||
</Subtitle>
|
||||
</div>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{!isLogin && (
|
||||
<InputGroup>
|
||||
<Label htmlFor="name">Имя</Label>
|
||||
<Input
|
||||
id="name"
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Введите ваше имя"
|
||||
placeholder="Имя"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required={!isLogin}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Введите ваш email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<InputGroup>
|
||||
<Label htmlFor="password">Пароль</Label>
|
||||
<Input
|
||||
id="password"
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Введите ваш пароль"
|
||||
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"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{error && <ErrorMessage>{error}</ErrorMessage>}
|
||||
<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>
|
||||
|
||||
<Button type="submit">
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<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
|
||||
</GoogleButton>
|
||||
</button>
|
||||
|
||||
<ToggleText>
|
||||
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}
|
||||
<button type="button" onClick={() => setIsLogin(!isLogin)}>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm text-warm-600 dark:text-warm-400">
|
||||
{isLogin ? 'Еще нет аккаунта?' : 'Уже есть аккаунт?'}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-accent hover:underline focus:outline-none"
|
||||
>
|
||||
{isLogin ? 'Зарегистрироваться' : 'Войти'}
|
||||
</button>
|
||||
</ToggleText>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,129 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const LoginClient = dynamic(() => import('./LoginClient'), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Container>
|
||||
<GlowingBackground>
|
||||
<Glow1 />
|
||||
<Glow2 />
|
||||
<Glow3 />
|
||||
</GlowingBackground>
|
||||
|
||||
<Content>
|
||||
<Logo>
|
||||
<span>Neo</span> Movies
|
||||
</Logo>
|
||||
|
||||
<GlassCard>
|
||||
<LoginClient />
|
||||
</GlassCard>
|
||||
</Content>
|
||||
</Container>
|
||||
);
|
||||
return <LoginClient />;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: #0a0a0a;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Content = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Logo = styled.h1`
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
color: #2196f3;
|
||||
}
|
||||
`;
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 3rem;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const GlowingBackground = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const Glow = styled.div`
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.3;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-30px, 30px); }
|
||||
}
|
||||
`;
|
||||
|
||||
const Glow1 = styled(Glow)`
|
||||
background: #2196f3;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
`;
|
||||
|
||||
const Glow2 = styled(Glow)`
|
||||
background: #9c27b0;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
bottom: -150px;
|
||||
right: -150px;
|
||||
animation-delay: -5s;
|
||||
`;
|
||||
|
||||
const Glow3 = styled(Glow)`
|
||||
background: #00bcd4;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: 100px;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
`;
|
||||
@@ -1,234 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { moviesAPI } from '@/lib/neoApi';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
import type { MovieDetails } from '@/lib/api';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kbox: any;
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 0 24px;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const MovieInfo = styled.div`
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const PosterContainer = styled.div`
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const Poster = styled.img`
|
||||
width: 300px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 160px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Details = styled.div`
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: white;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Info = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const InfoItem = styled.span`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const GenreList = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const Genre = styled.span`
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
const Tagline = styled.div`
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Overview = styled.p`
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 0.95rem;
|
||||
text-align: justify;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const WatchButton = styled.button`
|
||||
background: #e50914;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f40612;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerSection = styled.div`
|
||||
margin-top: 2rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
color: #ff4444;
|
||||
`;
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface MovieContentProps {
|
||||
movieId: string;
|
||||
@@ -236,9 +16,11 @@ interface MovieContentProps {
|
||||
}
|
||||
|
||||
export default function MovieContent({ movieId, initialMovie }: MovieContentProps) {
|
||||
const { settings } = useSettings();
|
||||
const [movie] = useState<MovieDetails>(initialMovie);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
@@ -254,62 +36,135 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
|
||||
fetchImdbId();
|
||||
}, [movieId]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Content>
|
||||
<MovieInfo>
|
||||
<PosterContainer>
|
||||
<Poster
|
||||
src={getImageUrl(movie.poster_path)}
|
||||
alt={movie.title}
|
||||
loading="eager"
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{/* Left Column: Poster */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(movie.poster_path, 'w500')}
|
||||
alt={`Постер фильма ${movie.title}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</PosterContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Details>
|
||||
<Title>{movie.title}</Title>
|
||||
<Info>
|
||||
<InfoItem>Рейтинг: {movie.vote_average.toFixed(1)}</InfoItem>
|
||||
<InfoItem>Длительность: {movie.runtime} мин.</InfoItem>
|
||||
<InfoItem>Дата выхода: {formatDate(movie.release_date)}</InfoItem>
|
||||
</Info>
|
||||
<GenreList>
|
||||
{movie.genres.map(genre => (
|
||||
<Genre key={genre.id}>{genre.name}</Genre>
|
||||
{/* Middle Column: Details */}
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{movie.title}
|
||||
</h1>
|
||||
{movie.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{movie.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">Рейтинг: {movie.vote_average.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{movie.runtime} мин.</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{formatDate(movie.release_date)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{movie.genres.map((genre) => (
|
||||
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</GenreList>
|
||||
{movie.tagline && <Tagline>{movie.tagline}</Tagline>}
|
||||
<Overview>{movie.overview}</Overview>
|
||||
</div>
|
||||
|
||||
<ActionButtons>
|
||||
<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 && (
|
||||
<WatchButton
|
||||
onClick={() => document.getElementById('movie-player')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</ActionButtons>
|
||||
</Details>
|
||||
</MovieInfo>
|
||||
</div>
|
||||
|
||||
{/* Desktop-only Embedded Player */}
|
||||
{imdbId && (
|
||||
<PlayerSection id="movie-player">
|
||||
<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}
|
||||
/>
|
||||
</PlayerSection>
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Player for Mobile */}
|
||||
{isPlayerFullscreen && imdbId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<MoviePlayer
|
||||
id={movie.id.toString()}
|
||||
title={movie.title}
|
||||
poster={movie.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import MovieContent from './MovieContent';
|
||||
import type { MovieDetails } from '@/lib/api';
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 0 24px;
|
||||
`;
|
||||
|
||||
interface MoviePageProps {
|
||||
movieId: string;
|
||||
movie: MovieDetails | null;
|
||||
@@ -20,18 +13,18 @@ export default function MoviePage({ movieId, movie }: MoviePageProps) {
|
||||
if (!movie) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Container>
|
||||
<div className="w-full min-h-screen">
|
||||
<div>Фильм не найден</div>
|
||||
</Container>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<Container>
|
||||
<div className="w-full">
|
||||
<MovieContent movieId={movieId} initialMovie={movie} />
|
||||
</Container>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,178 +1,20 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #0a0a0a;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 3rem;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ErrorCode = styled.h1`
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: #2196f3;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
letter-spacing: 4px;
|
||||
text-shadow: 0 4px 32px rgba(33, 150, 243, 0.3);
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
color: #FFFFFF;
|
||||
margin: 20px 0;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 30px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const HomeButton = styled(Link)`
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1976d2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
const GlowingBackground = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Glow = styled.div`
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.3;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-30px, 30px); }
|
||||
}
|
||||
`;
|
||||
|
||||
const Glow1 = styled(Glow)`
|
||||
background: #2196f3;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
`;
|
||||
|
||||
const Glow2 = styled(Glow)`
|
||||
background: #9c27b0;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
bottom: -150px;
|
||||
right: -150px;
|
||||
animation-delay: -5s;
|
||||
`;
|
||||
|
||||
const Glow3 = styled(Glow)`
|
||||
background: #00bcd4;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: 100px;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
`;
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{isClient && (
|
||||
<GlowingBackground className={isClient ? 'visible' : ''}>
|
||||
<Glow1 />
|
||||
<Glow2 />
|
||||
<Glow3 />
|
||||
</GlowingBackground>
|
||||
)}
|
||||
|
||||
<Content>
|
||||
<GlassCard>
|
||||
<ErrorCode>404</ErrorCode>
|
||||
<Title>Упс... Страница не найдена</Title>
|
||||
<Description>
|
||||
К сожалению, запрашиваемая страница не найдена.
|
||||
<br />
|
||||
Возможно, она была удалена или перемещена.
|
||||
</Description>
|
||||
<HomeButton href="/">Вернуться на главную</HomeButton>
|
||||
</GlassCard>
|
||||
</Content>
|
||||
</Container>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
|
||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8 text-center">
|
||||
<h1 className="text-5xl font-bold mb-4">404</h1>
|
||||
<p className="text-lg text-muted-foreground mb-6">Страница не найдена</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-accent text-white px-6 py-3 rounded-lg font-medium hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2"
|
||||
>
|
||||
На главную
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
213
src/app/page.tsx
213
src/app/page.tsx
@@ -1,184 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { HeartIcon } from '@/components/Icons/HeartIcon';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import { useMovies } from '@/hooks/useMovies';
|
||||
import { useMovies, MovieCategory } from '@/hooks/useMovies';
|
||||
import MovieTile from '@/components/MovieTile';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
padding-top: 84px;
|
||||
|
||||
@media (min-width: 769px) {
|
||||
padding-left: 264px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeaturedMovie = styled.div<{ $backdrop: string }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background-image: ${props => `url(${props.$backdrop})`};
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.8) 100%);
|
||||
}
|
||||
`;
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const FeaturedContent = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 800px;
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const GenreTags = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const GenreTag = styled.span`
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const Overview = styled.p`
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const WatchButton = styled.button`
|
||||
padding: 0.75rem 2rem;
|
||||
background: #e50914;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f40612;
|
||||
}
|
||||
`;
|
||||
|
||||
const MoviesGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
`;
|
||||
import HorizontalSlider from '@/components/HorizontalSlider';
|
||||
|
||||
export default function HomePage() {
|
||||
const { movies, featuredMovie, loading, error, totalPages, currentPage, setPage } = useMovies(1);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<MovieCategory>('popular');
|
||||
const { movies, loading, error, totalPages, currentPage, setPage } = useMovies({ category: activeTab });
|
||||
|
||||
if (loading && !movies.length) {
|
||||
return (
|
||||
<Container>
|
||||
<div>Загрузка...</div>
|
||||
</Container>
|
||||
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<div>{error}</div>
|
||||
</Container>
|
||||
<div className="flex min-h-[calc(100vh-128px)] items-center justify-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredMovies = selectedGenre
|
||||
? movies.filter(movie => movie.genre_ids.includes(parseInt(selectedGenre)))
|
||||
: movies;
|
||||
const sliderMovies = movies.slice(0, 10);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{featuredMovie && (
|
||||
<FeaturedMovie $backdrop={getImageUrl(featuredMovie.backdrop_path, 'original')}>
|
||||
<Overlay>
|
||||
<FeaturedContent>
|
||||
<GenreTags>
|
||||
{featuredMovie.genres?.map(genre => (
|
||||
<GenreTag key={genre.id}>{genre.name}</GenreTag>
|
||||
))}
|
||||
</GenreTags>
|
||||
<Title>{featuredMovie.title}</Title>
|
||||
<Overview>{featuredMovie.overview}</Overview>
|
||||
<ButtonContainer>
|
||||
<Link href={`/movie/${featuredMovie.id}`}>
|
||||
<WatchButton>Смотреть</WatchButton>
|
||||
</Link>
|
||||
<FavoriteButton
|
||||
mediaId={featuredMovie.id.toString()}
|
||||
mediaType="movie"
|
||||
title={featuredMovie.title}
|
||||
posterPath={featuredMovie.poster_path}
|
||||
/>
|
||||
</ButtonContainer>
|
||||
</FeaturedContent>
|
||||
</Overlay>
|
||||
</FeaturedMovie>
|
||||
)}
|
||||
<main className="min-h-screen bg-background px-4 py-6 text-foreground md:px-6 lg:px-8">
|
||||
<div className="container mx-auto">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('popular')}
|
||||
className={`${
|
||||
activeTab === 'popular'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Популярные
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('now_playing')}
|
||||
className={`${
|
||||
activeTab === 'now_playing'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Новинки
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('top_rated')}
|
||||
className={`${
|
||||
activeTab === 'top_rated'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-500'
|
||||
} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium`}
|
||||
>
|
||||
Топ рейтинга
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<MoviesGrid>
|
||||
{filteredMovies.map(movie => (
|
||||
<MovieCard key={movie.id} movie={movie} />
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{movies.map((movie) => (
|
||||
<MovieTile key={movie.id} movie={movie} />
|
||||
))}
|
||||
</MoviesGrid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import styled from 'styled-components';
|
||||
import GlassCard from '@/components/GlassCard';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
background-color: #0a0a0a;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const ProfileHeader = styled.div`
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const Avatar = styled.div`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: #2196f3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
margin: 0 auto 1rem;
|
||||
border: 4px solid #fff;
|
||||
`;
|
||||
|
||||
const Name = styled.h1`
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Email = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0.5rem 0 0;
|
||||
`;
|
||||
|
||||
const SignOutButton = styled.button`
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-top: 1rem;
|
||||
|
||||
&:hover {
|
||||
background: #ff2020;
|
||||
}
|
||||
`;
|
||||
import { Loader2, LogOut } from 'lucide-react';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { logout } = useAuth();
|
||||
@@ -93,34 +29,38 @@ export default function ProfilePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container>
|
||||
<Content>
|
||||
<GlassCard>
|
||||
<div>Загрузка...</div>
|
||||
</GlassCard>
|
||||
</Content>
|
||||
</Container>
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#F9F6EE] dark:bg-gray-900">
|
||||
<Loader2 className="h-16 w-16 animate-spin text-red-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userName) {
|
||||
// This can happen briefly before redirect, or if localStorage is cleared.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Content>
|
||||
<GlassCard>
|
||||
<ProfileHeader>
|
||||
<Avatar>
|
||||
<div className="min-h-screen w-full bg-[#F9F6EE] dark:bg-[#1e1e1e] pt-24 sm:pt-32">
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-[#49372E] p-8 shadow-lg">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-6 flex h-28 w-28 items-center justify-center rounded-full bg-gray-200 dark:bg-white/10 text-4xl font-bold text-gray-700 dark:text-gray-200 ring-4 ring-gray-100 dark:ring-white/5">
|
||||
{userName?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
|
||||
</Avatar>
|
||||
<Name>{userName}</Name>
|
||||
<Email>{userEmail}</Email>
|
||||
<SignOutButton onClick={handleSignOut}>Выйти</SignOutButton>
|
||||
</ProfileHeader>
|
||||
</GlassCard>
|
||||
</Content>
|
||||
</Container>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{userName}</h1>
|
||||
<p className="mt-2 text-base text-gray-500 dark:text-gray-300">{userEmail}</p>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="mt-8 inline-flex items-center gap-2.5 rounded-lg bg-red-600 px-6 py-3 text-base font-semibold text-white shadow-md transition-colors hover:bg-red-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
61
src/app/search/page.tsx
Normal file
61
src/app/search/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Movie, TVShow, moviesAPI, tvAPI } from '@/lib/api';
|
||||
import MovieCard from '@/components/MovieCard';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const [results, setResults] = useState<(Movie | TVShow)[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
moviesAPI.searchMovies(currentQuery),
|
||||
tvAPI.searchShows(currentQuery)
|
||||
]).then(([movieResults, tvResults]) => {
|
||||
const combined = [...(movieResults.data.results || []), ...(tvResults.data.results || [])];
|
||||
setResults(combined.sort((a, b) => b.vote_count - a.vote_count));
|
||||
}).catch(error => {
|
||||
console.error('Search failed:', error);
|
||||
setResults([]);
|
||||
}).finally(() => setLoading(false));
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground w-full px- sm:px lg:px-8 py-8">
|
||||
{searchParams.get('q') && (
|
||||
<h1 className="text-2xl font-bold mb-8">
|
||||
Результаты поиска для: <span className="text-primary">"{searchParams.get('q')}"</span>
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center">Загрузка...</div>
|
||||
) : results.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{results.map(item => (
|
||||
<MovieCard key={`${item.id}-${'title' in item ? 'movie' : 'tv'}`} movie={item} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
searchParams.get('q') && <div className="text-center">Ничего не найдено.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
src/app/tv/[id]/TVContent.tsx
Normal file
173
src/app/tv/[id]/TVContent.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { tvAPI } from '@/lib/api';
|
||||
import { getImageUrl } from '@/lib/neoApi';
|
||||
import type { TVShowDetails } from '@/lib/api';
|
||||
import MoviePlayer from '@/components/MoviePlayer';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PlayCircle, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface TVContentProps {
|
||||
showId: string;
|
||||
initialShow: TVShowDetails;
|
||||
}
|
||||
|
||||
export default function TVContent({ showId, initialShow }: TVContentProps) {
|
||||
const [show] = useState<TVShowDetails>(initialShow);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
||||
const [isControlsVisible, setIsControlsVisible] = useState(false);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
try {
|
||||
// Используем dedicated эндпоинт для получения IMDb ID
|
||||
const { data } = await tvAPI.getImdbId(showId);
|
||||
if (data?.imdb_id) {
|
||||
setImdbId(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching IMDb ID:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Проверяем, есть ли ID в initialShow, чтобы избежать лишнего запроса
|
||||
if (initialShow.external_ids?.imdb_id) {
|
||||
setImdbId(initialShow.external_ids.imdb_id);
|
||||
} else {
|
||||
fetchImdbId();
|
||||
}
|
||||
}, [showId, initialShow.external_ids]);
|
||||
|
||||
const showControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setIsControlsVisible(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setIsControlsVisible(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleOpenPlayer = () => {
|
||||
setIsPlayerFullscreen(true);
|
||||
showControls();
|
||||
};
|
||||
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerFullscreen(false);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<div className="sticky top-24 max-w-sm mx-auto md:max-w-none md:mx-0">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-lg shadow-lg">
|
||||
<Image
|
||||
src={getImageUrl(show.poster_path, 'w500')}
|
||||
alt={`Постер сериала ${show.name}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{show.name}
|
||||
</h1>
|
||||
{show.tagline && (
|
||||
<p className="mt-1 text-lg text-muted-foreground">{show.tagline}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium">Рейтинг: {show.vote_average.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">Сезонов: {show.number_of_seasons}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{formatDate(show.first_air_date)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.genres.map((genre) => (
|
||||
<span key={genre.id} className="rounded-full bg-secondary text-secondary-foreground px-3 py-1 text-xs font-medium">
|
||||
{genre.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4 text-base text-muted-foreground">
|
||||
<p>{show.overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{imdbId && (
|
||||
<button
|
||||
onClick={handleOpenPlayer}
|
||||
className="md:hidden flex items-center justify-center gap-2 rounded-md bg-red-500 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-red-600"
|
||||
>
|
||||
<PlayCircle size={20} />
|
||||
<span>Смотреть</span>
|
||||
</button>
|
||||
)}
|
||||
<FavoriteButton
|
||||
mediaId={show.id.toString()}
|
||||
mediaType="tv"
|
||||
title={show.name}
|
||||
posterPath={show.poster_path}
|
||||
className="!bg-secondary !text-secondary-foreground hover:!bg-secondary/80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{imdbId && (
|
||||
<div id="movie-player" className="mt-10 hidden md:block rounded-lg bg-secondary/50 p-4 shadow-inner">
|
||||
<MoviePlayer
|
||||
id={show.id.toString()}
|
||||
title={show.name}
|
||||
poster={show.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlayerFullscreen && imdbId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black"
|
||||
onMouseMove={showControls}
|
||||
onClick={showControls}
|
||||
>
|
||||
<MoviePlayer
|
||||
id={show.id.toString()}
|
||||
title={show.name}
|
||||
poster={show.poster_path || ''}
|
||||
imdbId={imdbId}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClosePlayer}
|
||||
className={`absolute top-1/2 left-4 -translate-y-1/2 z-50 rounded-full bg-black/50 p-2 text-white transition-opacity duration-300 hover:bg-black/75 ${isControlsVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/app/tv/[id]/TVPage.tsx
Normal file
30
src/app/tv/[id]/TVPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import TVContent from '@/app/tv/[id]/TVContent';
|
||||
import type { TVShowDetails } from '@/lib/api';
|
||||
|
||||
interface TVPageProps {
|
||||
showId: string;
|
||||
show: TVShowDetails | null;
|
||||
}
|
||||
|
||||
export default function TVPage({ showId, show }: TVPageProps) {
|
||||
if (!show) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="w-full min-h-screen">
|
||||
<div>Сериал не найден</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="w-full">
|
||||
<TVContent showId={showId} initialShow={show} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,46 @@
|
||||
import { Metadata } from 'next';
|
||||
import TVShowPage from './TVShowPage';
|
||||
import { tvAPI } from '@/lib/api';
|
||||
import TVPage from '@/app/tv/[id]/TVPage';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { tvShowsAPI } from '@/lib/neoApi';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}
|
||||
|
||||
// Generate SEO metadata
|
||||
export async function generateMetadata(
|
||||
props: { params: { id: string } }
|
||||
): Promise<Metadata> {
|
||||
// Генерация метаданных для страницы
|
||||
export async function generateMetadata(props: Promise<PageProps>): Promise<Metadata> {
|
||||
const { params } = await props;
|
||||
try {
|
||||
const showId = props.params.id;
|
||||
const { data: show } = await tvShowsAPI.getTVShow(showId);
|
||||
const showId = params.id;
|
||||
const { data: show } = await tvAPI.getShow(showId);
|
||||
|
||||
return {
|
||||
title: `${show.name} - NeoMovies`,
|
||||
description: show.overview,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating TV metadata', error);
|
||||
console.error('Error generating metadata:', error);
|
||||
return {
|
||||
title: 'Сериал - NeoMovies',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение данных для страницы
|
||||
async function getData(id: string) {
|
||||
try {
|
||||
const response = await tvShowsAPI.getTVShow(id).then(res => res.data);
|
||||
return { id, show: response };
|
||||
const { data: show } = await tvAPI.getShow(id);
|
||||
return { id, show };
|
||||
} catch (error) {
|
||||
console.error('Error fetching show:', error);
|
||||
return { id, show: null };
|
||||
throw new Error('Failed to fetch TV show');
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
// В Next.js 14 нужно сначала использовать параметры в асинхронной функции
|
||||
try {
|
||||
const tvShowId = props.params.id;
|
||||
const data = await getData(tvShowId);
|
||||
return <TVShowPage tvShowId={data.id} show={data.show} />;
|
||||
} catch (error) {
|
||||
console.error('Error loading TV show page:', error);
|
||||
return <div>Ошибка загрузки страницы сериала</div>;
|
||||
}
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = params;
|
||||
const data = await getData(id);
|
||||
return <TVPage showId={data.id} show={data.show} />;
|
||||
}
|
||||
|
||||
@@ -1,142 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { authAPI } from '@/lib/authApi';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authAPI } from '../../lib/authApi';
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 2rem;
|
||||
`;
|
||||
|
||||
const CodeInput = styled.input`
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
letter-spacing: normal;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
const VerifyButton = styled.button`
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #2196f3, #1e88e5);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to right, #1e88e5, #1976d2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResendButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2196f3;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #f44336;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export function VerificationClient({ email }: { email: string }) {
|
||||
export default function VerificationClient() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const router = useRouter();
|
||||
const { verifyCode, login } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('email');
|
||||
const { verifyCode, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout;
|
||||
if (countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [countdown]);
|
||||
}, [countdown, email, router]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.length !== 6) {
|
||||
setError('Код должен состоять из 6 цифр');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const password = localStorage.getItem('password');
|
||||
if (!password || !email) {
|
||||
throw new Error('Не удалось получить данные для входа');
|
||||
}
|
||||
|
||||
await verifyCode(code);
|
||||
await login(email, password);
|
||||
localStorage.removeItem('password');
|
||||
router.replace('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
||||
} finally {
|
||||
@@ -144,54 +56,71 @@ export function VerificationClient({ email }: { email: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
const handleResendCode = async () => {
|
||||
if (countdown > 0 || !email) return;
|
||||
|
||||
setError('');
|
||||
setIsResending(true);
|
||||
|
||||
try {
|
||||
await authAPI.resendCode(email);
|
||||
setCountdown(60);
|
||||
} catch (err) {
|
||||
setError('Не удалось отправить код');
|
||||
setError(err instanceof Error ? err.message : 'Не удалось отправить код');
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
<Title>Подтвердите ваш email</Title>
|
||||
<Subtitle>Мы отправили код подтверждения на {email}</Subtitle>
|
||||
</div>
|
||||
<div className="w-full max-w-md bg-warm-50 dark:bg-warm-900 rounded-lg shadow-lg p-8">
|
||||
<h2 className="text-xl font-bold text-center mb-2 text-foreground">Подтверждение email</h2>
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Мы отправили код подтверждения на {email}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<CodeInput
|
||||
<input
|
||||
type="text"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, '');
|
||||
setCode(value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Введите код"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent text-warm-900 dark:text-warm-50 placeholder:text-warm-400 text-center text-lg tracking-wider"
|
||||
/>
|
||||
{error && <ErrorMessage>{error}</ErrorMessage>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<VerifyButton
|
||||
onClick={handleVerify}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || code.length !== 6}
|
||||
className="w-full py-3 px-4 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Проверка...' : 'Подтвердить'}
|
||||
</VerifyButton>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ResendButton
|
||||
onClick={handleResend}
|
||||
disabled={countdown > 0 || isLoading}
|
||||
>
|
||||
{countdown > 0
|
||||
? `Отправить код повторно (${countdown}с)`
|
||||
: 'Отправить код повторно'}
|
||||
</ResendButton>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={countdown > 0 || isResending}
|
||||
className="text-accent hover:underline focus:outline-none disabled:opacity-50 disabled:no-underline text-sm"
|
||||
>
|
||||
{isResending
|
||||
? 'Отправка...'
|
||||
: countdown > 0
|
||||
? `Отправить код повторно через ${countdown} сек`
|
||||
: 'Отправить код повторно'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +1,15 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { GlassCard } from '@/components/GlassCard';
|
||||
import { VerificationClient } from './VerificationClient';
|
||||
import styled from 'styled-components';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const Container = styled.div`
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: #0a0a0a;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Content = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const GlowingBackground = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const Glow = styled.div`
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.3;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-30px, 30px); }
|
||||
}
|
||||
`;
|
||||
|
||||
const Glow1 = styled(Glow)`
|
||||
background: #2196f3;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
`;
|
||||
|
||||
const Glow2 = styled(Glow)`
|
||||
background: #9c27b0;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
bottom: -150px;
|
||||
right: -150px;
|
||||
animation-delay: -5s;
|
||||
`;
|
||||
|
||||
const Glow3 = styled(Glow)`
|
||||
background: #00bcd4;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: 100px;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
`;
|
||||
|
||||
function VerifyContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const email = searchParams.get('email');
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
const VerificationClient = dynamic(() => import('./VerificationClient'), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Container>
|
||||
<GlowingBackground>
|
||||
<Glow1 />
|
||||
<Glow2 />
|
||||
<Glow3 />
|
||||
</GlowingBackground>
|
||||
<Content>
|
||||
<GlassCard>
|
||||
<VerificationClient email={email} />
|
||||
</GlassCard>
|
||||
</Content>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background text-foreground">
|
||||
<VerificationClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { ReactNode } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const Layout = styled.div<{ $hasNavbar: boolean }>`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: #0E0E0E;
|
||||
`;
|
||||
const Layout = ({ children }: { children: ReactNode }) => (
|
||||
<div className="min-h-screen flex bg-gray-900 text-white">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Main = styled.main<{ $hasNavbar: boolean }>`
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
|
||||
${props => props.$hasNavbar && `
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 60px;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
margin-left: 240px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
const Main = ({ children }: { children: ReactNode }) => (
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -31,12 +21,15 @@ interface AppLayoutProps {
|
||||
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
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 (
|
||||
<Layout $hasNavbar={!hideNavbar}>
|
||||
{!hideNavbar && <Navbar />}
|
||||
<Main $hasNavbar={!hideNavbar}>{children}</Main>
|
||||
<Layout>
|
||||
<Main>{children}</Main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { Category } from '@/lib/api';
|
||||
|
||||
interface CategoryCardProps {
|
||||
category: Category;
|
||||
backgroundUrl?: string;
|
||||
backgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
// Словарь цветов для разных жанров
|
||||
@@ -47,69 +46,9 @@ function getCategoryColor(categoryId: number): string {
|
||||
return genreColors[categoryId] || '#3949AB'; // Индиго как запасной вариант
|
||||
}
|
||||
|
||||
const CardContainer = styled.div<{ $bgUrl: string; $bgColor: string }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
background-image: url(${props => props.$bgUrl || '/images/placeholder.jpg'});
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${props => props.$bgColor};
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const CategoryName = styled.h3`
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const CategoryCount = styled.p`
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
margin: 0.5rem 0 0;
|
||||
`;
|
||||
|
||||
function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
|
||||
const router = useRouter();
|
||||
const [imageUrl, setImageUrl] = useState<string>(backgroundUrl || '/images/placeholder.jpg');
|
||||
const [imageUrl] = useState<string>(backgroundUrl || '/images/placeholder.jpg');
|
||||
|
||||
const categoryColor = getCategoryColor(category.id);
|
||||
|
||||
@@ -118,18 +57,22 @@ function CategoryCard({ category, backgroundUrl }: CategoryCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
$bgUrl={imageUrl}
|
||||
$bgColor={categoryColor}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
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>
|
||||
<CategoryName>{category.name}</CategoryName>
|
||||
<CategoryCount>Фильмы и сериалы</CategoryCount>
|
||||
</CardContent>
|
||||
</CardContainer>
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-70 group-hover:opacity-80"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
></div>
|
||||
<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';
|
||||
|
||||
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import StyledComponentsRegistry from '@/lib/registry';
|
||||
import Navbar from './Navbar';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
background: '#0f172a',
|
||||
text: '#ffffff',
|
||||
},
|
||||
};
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { useState } from 'react';
|
||||
import MobileNav from './MobileNav';
|
||||
|
||||
export function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledComponentsRegistry>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Navbar />
|
||||
{children}
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<HeaderBar onBurgerClick={() => setIsMobileMenuOpen(true)} />
|
||||
<MobileNav show={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />
|
||||
<main className="flex-1 w-full">{children}</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</StyledComponentsRegistry>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { favoritesAPI } from '@/lib/favoritesApi';
|
||||
import { Heart } from 'lucide-react';
|
||||
import styled from 'styled-components';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
import cn from 'classnames';
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
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 [isFavorite, setIsFavorite] = useState(false);
|
||||
|
||||
// Преобразуем mediaId в строку для сравнения
|
||||
const mediaIdString = mediaId.toString();
|
||||
|
||||
useEffect(() => {
|
||||
const checkFavorite = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const { data } = await favoritesAPI.checkFavorite(mediaIdString);
|
||||
setIsFavorite(!!data.isFavorite);
|
||||
@@ -55,7 +28,6 @@ export default function FavoriteButton({ mediaId, mediaType, title, posterPath,
|
||||
console.error('Error checking favorite status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkFavorite();
|
||||
}, [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 (
|
||||
<Button type="button" onClick={toggleFavorite} $isFavorite={isFavorite} className={className}>
|
||||
<Heart />
|
||||
{isFavorite ? 'В избранном' : 'В избранное'}
|
||||
</Button>
|
||||
<button type="button" onClick={toggleFavorite} className={buttonClasses}>
|
||||
<Heart size={20} className={cn({ 'fill-current': isFavorite })} />
|
||||
<span>{isFavorite ? 'В избранном' : 'В избранное'}</span>
|
||||
</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 Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import styled from 'styled-components';
|
||||
import { Movie, TVShow } from '@/types/movie';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useImageLoader } from '@/hooks/useImageLoader';
|
||||
@@ -18,6 +17,12 @@ interface MovieCardProps {
|
||||
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) {
|
||||
// Определяем, это фильм или сериал с помощью тип-гарда
|
||||
const isTV = isTVShow(movie);
|
||||
@@ -34,158 +39,39 @@ export default function MovieCard({ movie, priority = false }: MovieCardProps) {
|
||||
const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342'); // Используем поддерживаемый размер
|
||||
|
||||
return (
|
||||
<Card href={url}>
|
||||
<PosterWrapper>
|
||||
<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">
|
||||
<div className="relative aspect-[2/3]">
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder aria-label="Загрузка постера">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
</LoadingPlaceholder>
|
||||
<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-muted-foreground/50 border-t-primary" />
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<Poster
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`Постер ${title}`}
|
||||
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}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
className="object-cover"
|
||||
unoptimized // Отключаем оптимизацию Next.js, так как используем CDN
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<NoImagePlaceholder aria-label="Нет изображения">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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">
|
||||
<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" />
|
||||
</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)}
|
||||
</Rating>
|
||||
</PosterWrapper>
|
||||
<Content>
|
||||
<Title>{title}</Title>
|
||||
<Year>{date ? formatDate(date) : 'Без даты'}</Year>
|
||||
</Content>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-3">
|
||||
<h3 className="mb-1 block truncate text-sm font-medium">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{date ? formatDate(date) : 'Без даты'}</p>
|
||||
</div>
|
||||
</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';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { moviesAPI, api } from '@/lib/api';
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
interface MoviePlayerProps {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: 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();
|
||||
// containerRef removed – using direct iframe integration
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const [resolvedImdb, setResolvedImdb] = useState<string | null>(imdbId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
// setCurrentPlayer(settings.defaultPlayer);
|
||||
}
|
||||
}, [settings.defaultPlayer, isInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImdbId = async () => {
|
||||
if (imdbId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!imdbId) {
|
||||
const { data } = await moviesAPI.getMovie(id);
|
||||
if (!data?.imdb_id) {
|
||||
throw new Error('IMDb ID не найден');
|
||||
}
|
||||
if (!data?.imdb_id) throw new Error('IMDb ID не найден');
|
||||
setResolvedImdb(data.imdb_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching IMDb ID:', err);
|
||||
setError('Не удалось загрузить плеер. Пожалуйста, попробуйте позже.');
|
||||
setError('Не удалось получить информацию для плеера.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!resolvedImdb) {
|
||||
fetchImdbId();
|
||||
}
|
||||
}, [id, resolvedImdb]);
|
||||
}, [id, imdbId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPlayer = async () => {
|
||||
@@ -133,30 +45,16 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
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 {
|
||||
const response = await api.get(basePath, { params: queryParams });
|
||||
if (!response.data) {
|
||||
throw new Error('Empty response');
|
||||
}
|
||||
|
||||
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);
|
||||
let src: string | null = data.iframe || data.src || data.url || null;
|
||||
if (!src && typeof data === 'string') {
|
||||
const match = data.match(/<iframe[^>]*src="([^"]+)"/i);
|
||||
if (match && match[1]) src = match[1];
|
||||
}
|
||||
if (!src) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
if (!src) throw new Error('Invalid response format');
|
||||
setIframeSrc(src);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -164,51 +62,67 @@ export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerPr
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Не удалось загрузить плеер. Попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlayer();
|
||||
}, [id, resolvedImdb, isInitialized, settings.defaultPlayer]);
|
||||
}, [resolvedImdb, isInitialized, settings.defaultPlayer]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
setLoading(false);
|
||||
if (!resolvedImdb) {
|
||||
// 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) {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<div>{error}</div>
|
||||
<RetryButton onClick={handleRetry}>Попробовать снова</RetryButton>
|
||||
</ErrorContainer>
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg bg-red-100 p-6 text-center text-red-700">
|
||||
<AlertTriangle size={32} />
|
||||
<p>{error}</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const rootClasses = isFullscreen ? 'w-full h-full' : '';
|
||||
const playerContainerClasses = isFullscreen
|
||||
? 'relative w-full h-full bg-black'
|
||||
: 'relative w-full overflow-hidden rounded-lg bg-black pt-[56.25%]';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerContainer>
|
||||
<div className={rootClasses}>
|
||||
<div className={playerContainerClasses}>
|
||||
{iframeSrc ? (
|
||||
<StyledIframe src={iframeSrc} allow="fullscreen" loading="lazy" />
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
className="absolute left-0 top-0 h-full w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
loading && <LoadingContainer>Загрузка плеера...</LoadingContainer>
|
||||
loading && (
|
||||
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center text-warm-300">
|
||||
Загрузка плеера...
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
</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';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -69,20 +10,21 @@ export default function PageLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
if (is404Page) {
|
||||
return (
|
||||
<NotFoundContent>
|
||||
<h1>404</h1>
|
||||
<p>Страница не найдена</p>
|
||||
<a href="/">Вернуться на главную</a>
|
||||
</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 className="text-6xl font-bold m-0 text-blue-500">404</h1>
|
||||
<p className="text-2xl my-4 text-gray-300">Страница не найдена</p>
|
||||
<Link href="/" className="text-blue-500 font-medium hover:underline">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Navbar />
|
||||
<MainContent $isSettingsPage={isSettingsPage}>
|
||||
<div className="flex min-h-screen">
|
||||
<main className={`flex-1 overflow-hidden ${isSettingsPage ? 'flex justify-center pt-8' : ''}`}>
|
||||
{children}
|
||||
</MainContent>
|
||||
</Layout>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
currentPage: number;
|
||||
@@ -41,6 +8,27 @@ interface PaginationProps {
|
||||
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) {
|
||||
const maxVisiblePages = 5;
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
@@ -57,7 +45,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
};
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
if (page !== currentPage && page > 0 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
@@ -65,7 +53,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<PaginationContainer>
|
||||
<div className="flex items-center justify-center gap-2 my-8 text-foreground">
|
||||
<PageButton
|
||||
onClick={() => handlePageClick(1)}
|
||||
disabled={currentPage === 1}
|
||||
@@ -82,7 +70,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
{getPageNumbers().map(page => (
|
||||
<PageButton
|
||||
key={page}
|
||||
$active={page === currentPage}
|
||||
active={page === currentPage}
|
||||
onClick={() => handlePageClick(page)}
|
||||
>
|
||||
{page}
|
||||
@@ -101,6 +89,6 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
>
|
||||
»
|
||||
</PageButton>
|
||||
</PaginationContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { GlobalStyles } from '@/styles/GlobalStyles';
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
import { theme } from '@/styles/theme';
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider refetchInterval={0}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
|
||||
@@ -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 { 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 [featuredMovie, setFeaturedMovie] = useState<Movie | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
// Получаем featured фильм всегда с первой страницы
|
||||
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) => {
|
||||
const fetchMovies = useCallback(async (pageNum: number, movieCategory: MovieCategory) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMovies([]); // Очищаем текущие фильмы перед загрузкой новых
|
||||
|
||||
console.log('Загрузка страницы:', pageNum);
|
||||
const response = await moviesAPI.getPopular(pageNum);
|
||||
console.log('Получены данные:', {
|
||||
page: response.data.page,
|
||||
results: response.data.results.length,
|
||||
totalPages: response.data.total_pages
|
||||
});
|
||||
let response: { data: MovieResponse };
|
||||
|
||||
switch (movieCategory) {
|
||||
case 'top_rated':
|
||||
response = await moviesAPI.getTopRated(pageNum);
|
||||
break;
|
||||
case 'now_playing':
|
||||
response = await moviesAPI.getNowPlaying(pageNum);
|
||||
break;
|
||||
case 'popular':
|
||||
default:
|
||||
response = await moviesAPI.getPopular(pageNum);
|
||||
break;
|
||||
}
|
||||
|
||||
setMovies(response.data.results);
|
||||
setTotalPages(response.data.total_pages);
|
||||
setTotalPages(response.data.total_pages > 500 ? 500 : response.data.total_pages);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке фильмов:', err);
|
||||
console.error(`Ошибка при загрузке категории "${movieCategory}":`, err);
|
||||
setError('Произошла ошибка при загрузке фильмов');
|
||||
setMovies([]);
|
||||
} finally {
|
||||
@@ -54,32 +49,27 @@ export function useMovies(initialPage = 1) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Загружаем featured фильм при монтировании
|
||||
useEffect(() => {
|
||||
fetchFeaturedMovie();
|
||||
}, [fetchFeaturedMovie]);
|
||||
fetchMovies(page, category);
|
||||
}, [page, category, fetchMovies]);
|
||||
|
||||
// Загружаем фильмы при изменении страницы
|
||||
// Сбрасываем страницу на 1 при смене категории
|
||||
useEffect(() => {
|
||||
console.log('Изменение страницы на:', page);
|
||||
fetchMovies(page);
|
||||
}, [page, fetchMovies]);
|
||||
setPage(1);
|
||||
}, [category]);
|
||||
|
||||
// Обработчик изменения страницы
|
||||
const handlePageChange = useCallback(async (newPage: number) => {
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages) return;
|
||||
console.log('Смена страницы на:', newPage);
|
||||
setPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [totalPages]);
|
||||
|
||||
return {
|
||||
movies,
|
||||
featuredMovie,
|
||||
loading,
|
||||
error,
|
||||
totalPages,
|
||||
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
|
||||
getMovie(id: string | number) {
|
||||
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 plugin from "tailwindcss/plugin";
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
@@ -11,8 +13,52 @@ export default {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user