Update 103 files

- /public/file.svg
- /public/globe.svg
- /public/next.svg
- /public/vercel.svg
- /public/window.svg
- /public/google.svg
- /public/logo.png
- /src/eslint.config.mjs
- /src/api.ts
- /src/middleware.ts
- /src/app/favicon.ico
- /src/app/globals.css
- /src/app/layout.tsx
- /src/app/page.tsx
- /src/app/providers.tsx
- /src/app/not-found.tsx
- /src/app/error.tsx
- /src/app/metadata.ts
- /src/app/styles.tsx
- /src/app/api/auth/[...nextauth]/route.ts
- /src/app/api/auth/register/route.ts
- /src/app/api/auth/verify/route.ts
- /src/app/api/auth/check-verification/route.ts
- /src/app/api/auth/resend-code/route.ts
- /src/app/api/movies/search/route.ts
- /src/app/api/movies/sync/route.ts
- /src/app/api/admin/send-verification/route.ts
- /src/app/api/admin/verify-code/route.ts
- /src/app/api/admin/movies/route.ts
- /src/app/api/admin/movies/toggle-visibility/route.ts
- /src/app/api/admin/create/route.ts
- /src/app/api/admin/users/toggle-admin/route.ts
- /src/app/api/admin/toggle-admin/route.ts
- /src/app/login/page.tsx
- /src/app/login/LoginClient.tsx
- /src/app/verify/page.tsx
- /src/app/verify/VerificationClient.tsx
- /src/app/profile/page.tsx
- /src/app/movie/[id]/page.tsx
- /src/app/movie/[id]/MoviePage.tsx
- /src/app/movie/[id]/MovieContent.tsx
- /src/app/settings/page.tsx
- /src/app/tv/[id]/page.tsx
- /src/app/tv/[id]/TVShowPage.tsx
- /src/app/tv/[id]/TVShowContent.tsx
- /src/app/admin/login/page.tsx
- /src/app/admin/login/AdminLoginClient.tsx
- /src/lib/db.ts
- /src/lib/jwt.ts
- /src/lib/registry.tsx
- /src/lib/api.ts
- /src/lib/mongodb.ts
- /src/lib/mailer.ts
- /src/lib/auth.ts
- /src/lib/utils.ts
- /src/lib/email.ts
- /src/lib/movieSync.ts
- /src/models/User.ts
- /src/models/index.ts
- /src/models/Movie.ts
- /src/types/auth.ts
- /src/types/movie.ts
- /src/components/MovieCard.tsx
- /src/components/Notification.tsx
- /src/components/Pagination.tsx
- /src/components/GoogleIcon.tsx
- /src/components/StyleProvider.tsx
- /src/components/Providers.tsx
- /src/components/VerificationCodeInput.tsx
- /src/components/GlassCard.tsx
- /src/components/AppLayout.tsx
- /src/components/SearchModal.tsx
- /src/components/DarkReaderFix.tsx
- /src/components/ClientLayout.tsx
- /src/components/MenuItem.tsx
- /src/components/MoviePlayer.tsx
- /src/components/PageLayout.tsx
- /src/components/SettingsContent.tsx
- /src/components/Navbar.tsx
- /src/components/LayoutContent.tsx
- /src/components/SearchResults.tsx
- /src/components/Icons/Icons.tsx
- /src/components/Icons/HeartIcon.tsx
- /src/components/Icons/PlayIcon.tsx
- /src/components/admin/MovieSearch.tsx
- /src/hooks/useUser.ts
- /src/hooks/useMovies.ts
- /src/hooks/useSettings.ts
- /src/hooks/useSearch.ts
- /src/styles/GlobalStyles.ts
- /src/styles/GlobalStyles.tsx
- /src/providers/AuthProvider.tsx
- /src/data/movies.ts
- /types/next-auth.d.ts
- /middleware.ts
- /next.config.js
- /next-env.d.ts
- /package.json
- /postcss.config.mjs
- /README.md
- /tailwind.config.ts
- /tsconfig.json
- /package-lock.json
This commit is contained in:
2024-12-23 18:42:18 +00:00
parent 4ccfd581ad
commit ebf23e4246
103 changed files with 14273 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
'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 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;
}
`}
`;
interface AppLayoutProps {
children: ReactNode;
}
export default function AppLayout({ children }: AppLayoutProps) {
const pathname = usePathname();
const hideNavbar = pathname === '/login' || pathname === '/404' || pathname.startsWith('/verify');
return (
<Layout $hasNavbar={!hideNavbar}>
{!hideNavbar && <Navbar />}
<Main $hasNavbar={!hideNavbar}>{children}</Main>
</Layout>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'styled-components';
import StyledComponentsRegistry from '@/lib/registry';
import Navbar from './Navbar';
import { Toaster } from 'react-hot-toast';
const theme = {
colors: {
primary: '#3b82f6',
background: '#0f172a',
text: '#ffffff',
},
};
export function ClientLayout({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<StyledComponentsRegistry>
<ThemeProvider theme={theme}>
<Navbar />
{children}
<Toaster position="bottom-right" />
</ThemeProvider>
</StyledComponentsRegistry>
</SessionProvider>
);
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useEffect } from 'react';
export function DarkReaderFix() {
useEffect(() => {
const html = document.documentElement;
html.removeAttribute('data-darkreader-mode');
html.removeAttribute('data-darkreader-scheme');
}, []);
return null;
}

View File

@@ -0,0 +1,17 @@
import styled from 'styled-components';
export 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;
`;
export default GlassCard;

View File

@@ -0,0 +1,18 @@
'use client';
import Image from 'next/image';
const GoogleIcon = () => (
<Image
src="/google.svg"
alt="Google"
width={18}
height={18}
style={{
marginRight: '8px',
pointerEvents: 'none'
}}
/>
);
export default GoogleIcon;

View File

@@ -0,0 +1,5 @@
export const HeartIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);

View File

@@ -0,0 +1,80 @@
export const HomeIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 22V12h6v10" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const CategoryIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const HeartIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const DownloadIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 10l5 5 5-5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 15V3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const FriendsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="9" cy="7" r="4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const CommunityIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const HistoryIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 6v6l4 2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const LogoutIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 17l5-5-5-5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const SearchIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
);

View File

@@ -0,0 +1,18 @@
'use client';
import React from 'react';
export function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<path d="M8 5v14l11-7z" />
</svg>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import styled from 'styled-components';
import { Providers } from './Providers';
import { Toaster } from 'react-hot-toast';
import PageLayout from './PageLayout';
const MainContent = styled.div`
width: 100%;
min-height: 100vh;
`;
interface LayoutContentProps {
children: React.ReactNode;
}
export default function LayoutContent({ children }: LayoutContentProps) {
return (
<Providers>
<PageLayout>
<MainContent>
{children}
</MainContent>
</PageLayout>
<Toaster />
</Providers>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import styled from 'styled-components';
import Link from 'next/link';
interface MenuItemProps {
href?: string;
icon: React.ReactNode;
label: string;
subLabel?: string;
isActive?: boolean;
onClick?: (e: React.MouseEvent) => void;
}
const StyledMenuItem = styled.div<{ $active?: boolean }>`
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
color: ${props => props.$active ? '#fff' : 'rgba(255, 255, 255, 0.7)'};
text-decoration: none;
border-radius: 8px;
background: ${props => props.$active ? 'rgba(255, 255, 255, 0.1)' : 'transparent'};
transition: all 0.2s;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
svg {
width: 20px;
height: 20px;
opacity: ${props => props.$active ? 1 : 0.7};
}
`;
const ItemContent = styled.div`
flex: 1;
min-width: 0;
`;
const Label = styled.div`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const SubLabel = styled.div`
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
export default function MenuItem({ href, icon, label, subLabel, isActive, onClick }: MenuItemProps) {
const content = (
<StyledMenuItem $active={isActive} onClick={onClick}>
{icon}
<ItemContent>
<Label>{label}</Label>
{subLabel && <SubLabel>{subLabel}</SubLabel>}
</ItemContent>
</StyledMenuItem>
);
if (href) {
return (
<Link href={href} passHref style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}>
{content}
</Link>
);
}
return content;
}

View File

@@ -0,0 +1,93 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import styled from 'styled-components';
import { Movie } from '@/types/movie';
interface MovieCardProps {
movie: Movie;
}
export default function MovieCard({ movie }: MovieCardProps) {
const getRatingColor = (rating: number) => {
if (rating >= 7) return '#4CAF50';
if (rating >= 5) return '#FFC107';
return '#F44336';
};
const posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
: '/placeholder.jpg';
return (
<Card href={`/movie/${movie.id}`}>
<PosterWrapper>
<Poster
src={posterUrl}
alt={movie.title}
width={200}
height={300}
style={{ objectFit: 'cover' }}
/>
<Rating style={{ backgroundColor: getRatingColor(movie.vote_average) }}>
{movie.vote_average.toFixed(1)}
</Rating>
</PosterWrapper>
<Content>
<Title>{movie.title}</Title>
<Year>{new Date(movie.release_date).getFullYear()}</Year>
</Content>
</Card>
);
}
const Card = styled(Link)`
position: relative;
border-radius: 16px;
overflow: hidden;
background: #242424;
text-decoration: none;
color: inherit;
`;
const PosterWrapper = styled.div`
position: relative;
aspect-ratio: 2/3;
`;
const Poster = styled(Image)`
width: 100%;
height: 100%;
`;
const Content = styled.div`
padding: 12px;
`;
const Title = styled.h3`
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Year = styled.div`
font-size: 12px;
color: #808191;
margin-top: 4px;
`;
const Rating = styled.div`
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
border-radius: 6px;
font-size: 17px;
font-weight: 600;
color: white;
`;

View File

@@ -0,0 +1,234 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useSettings } from '@/hooks/useSettings';
import { moviesAPI } 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;
}
`;
interface MoviePlayerProps {
id: string;
title: string;
poster: string;
imdbId?: string;
}
export default function MoviePlayer({ id, title, poster, imdbId }: MoviePlayerProps) {
const { settings, isInitialized } = useSettings();
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPlayer, setCurrentPlayer] = useState(settings.defaultPlayer);
useEffect(() => {
if (isInitialized) {
setCurrentPlayer(settings.defaultPlayer);
}
}, [settings.defaultPlayer, isInitialized]);
useEffect(() => {
const fetchImdbId = async () => {
try {
setLoading(true);
setError(null);
if (!imdbId) {
const newImdbId = await moviesAPI.getImdbId(id);
if (!newImdbId) {
throw new Error('IMDb ID не найден');
}
imdbId = newImdbId;
}
} catch (err) {
console.error('Error fetching IMDb ID:', err);
setError('Не удалось загрузить плеер. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchImdbId();
}, [id, imdbId]);
useEffect(() => {
if (settings.defaultPlayer === 'lumex') {
return;
}
// Очищаем контейнер при изменении плеера
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
const playerDiv = document.createElement('div');
playerDiv.className = 'kinobox_player';
containerRef.current?.appendChild(playerDiv);
const script = document.createElement('script');
script.src = 'https://kinobox.tv/kinobox.min.js';
script.async = true;
script.onload = () => {
if (window.kbox && containerRef.current) {
const playerConfig = {
search: {
imdb: imdbId,
title: title
},
menu: {
enable: false,
default: 'menu_list',
mobile: 'menu_button',
format: '{N} :: {T} ({Q})',
limit: 5,
open: false,
},
notFoundMessage: 'Видео не найдено.',
players: {
alloha: { enable: settings.defaultPlayer === 'alloha', position: 1 },
collaps: { enable: settings.defaultPlayer === 'collaps', position: 2 },
lumex: { enable: settings.defaultPlayer === 'lumex', position: 3 }
},
params: {
all: {
poster: poster
}
}
};
window.kbox('.kinobox_player', playerConfig);
setLoading(false);
}
};
document.body.appendChild(script);
return () => {
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
const existingScript = document.querySelector('script[src="https://kinobox.tv/kinobox.min.js"]');
if (existingScript) {
document.body.removeChild(existingScript);
}
};
}, [id, title, poster, imdbId, settings.defaultPlayer]);
const handleRetry = () => {
setLoading(true);
setError(null);
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
setLoading(false);
};
if (error) {
return (
<ErrorContainer>
<div>{error}</div>
<RetryButton onClick={handleRetry}>Попробовать снова</RetryButton>
</ErrorContainer>
);
}
return (
<>
<PlayerContainer>
{settings.defaultPlayer === 'lumex' && imdbId ? (
<StyledIframe
src={`${process.env.NEXT_PUBLIC_LUMEX_URL}?imdb_id=${imdbId}`}
allow="fullscreen"
loading="lazy"
/>
) : (
<>
<div ref={containerRef} style={{ width: '100%', height: '100%', position: 'absolute' }} />
{loading && <LoadingContainer>Загрузка плеера...</LoadingContainer>}
</>
)}
</PlayerContainer>
{settings.defaultPlayer !== 'lumex' && (
<DownloadMessage>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Для возможности скачивания фильма выберите плеер Lumex в настройках
</DownloadMessage>
)}
</>
);
}

406
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,406 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useSession, signOut } from 'next-auth/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: rgba(18, 18, 23, 0.8);
backdrop-filter: blur(10px);
z-index: 50;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
@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: 60px;
left: 0;
right: 0;
bottom: 0;
background: rgba(18, 18, 23, 0.95);
backdrop-filter: blur(10px);
transform: translateX(${props => props.$isOpen ? '0' : '100%'});
transition: transform 0.3s ease-in-out;
padding: 1rem;
z-index: 49;
overflow-y: auto;
@media (min-width: 769px) {
display: none;
}
`;
const MobileMenuItem = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
color: white;
text-decoration: none;
border-radius: 12px;
transition: background-color 0.2s;
font-size: 1rem;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
}
`;
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 { data: session } = useSession();
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>
<div onClick={() => router.push('/')} style={{ cursor: 'pointer' }}>
<Logo as="div">
Neo <span>Movies</span>
</Logo>
</div>
</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>
{session ? (
<UserProfile>
<UserButton onClick={() => router.push('/profile')} style={{ cursor: 'pointer' }}>
<UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{session.user?.name}</div>
<div>{session.user?.email}</div>
</UserInfo>
</UserButton>
</UserProfile>
) : (
<AuthButtons>
<div onClick={() => router.push('/login')} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
Войти
</MobileMenuItem>
</div>
</AuthButtons>
)}
</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}>
{session ? (
<UserProfile>
<UserButton onClick={() => signOut()}>
<UserAvatar>
{session.user?.name?.split(' ').map(n => n[0]).join('').toUpperCase() || ''}
</UserAvatar>
<UserInfo>
<div>{session.user?.name}</div>
<div>{session.user?.email}</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>
))}
{!session && (
<AuthButtons>
<div onClick={() => {
router.push('/login');
setIsMobileMenuOpen(false);
}} style={{ cursor: 'pointer' }}>
<MobileMenuItem as="div" style={{ justifyContent: 'center', background: '#3b82f6' }}>
Войти
</MobileMenuItem>
</div>
</AuthButtons>
)}
</MobileMenu>
{/* Search Modal */}
{isSearchOpen && (
<SearchModal onClose={() => setIsSearchOpen(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect } from 'react';
import styled, { keyframes } from 'styled-components';
const slideIn = keyframes`
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
`;
const Container = styled.div<{ type: 'success' | 'error' | 'info' }>`
position: fixed;
top: 1rem;
right: 1rem;
padding: 1rem;
border-radius: 4px;
background: ${({ type }) => {
switch (type) {
case 'success':
return '#4caf50';
case 'error':
return '#f44336';
case 'info':
return '#2196f3';
}
}};
color: white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
animation: ${slideIn} 0.3s ease-out;
z-index: 1000;
`;
interface NotificationProps {
message: string;
type: 'success' | 'error' | 'info';
onClose: () => void;
duration?: number;
}
export default function Notification({
message,
type,
onClose,
duration = 3000,
}: NotificationProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
return <Container type={type}>{message}</Container>;
}

View File

@@ -0,0 +1,88 @@
'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;
}
}
`;
export default function PageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isSettingsPage = pathname === '/settings';
const is404Page = pathname === '/404' || pathname.includes('/not-found');
if (is404Page) {
return (
<NotFoundContent>
<h1>404</h1>
<p>Страница не найдена</p>
<a href="/">Вернуться на главную</a>
</NotFoundContent>
);
}
return (
<Layout>
<Navbar />
<MainContent $isSettingsPage={isSettingsPage}>
{children}
</MainContent>
</Layout>
);
}

View File

@@ -0,0 +1,106 @@
'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;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2);
const getPageNumbers = () => {
let start = Math.max(1, currentPage - halfVisible);
let end = Math.min(totalPages, start + maxVisiblePages - 1);
if (end - start + 1 < maxVisiblePages) {
start = Math.max(1, end - maxVisiblePages + 1);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
};
const handlePageClick = (page: number) => {
if (page !== currentPage) {
onPageChange(page);
}
};
if (totalPages <= 1) return null;
return (
<PaginationContainer>
<PageButton
onClick={() => handlePageClick(1)}
disabled={currentPage === 1}
>
«
</PageButton>
<PageButton
onClick={() => handlePageClick(currentPage - 1)}
disabled={currentPage === 1}
>
</PageButton>
{getPageNumbers().map(page => (
<PageButton
key={page}
$active={page === currentPage}
onClick={() => handlePageClick(page)}
>
{page}
</PageButton>
))}
<PageButton
onClick={() => handlePageClick(currentPage + 1)}
disabled={currentPage === totalPages}
>
</PageButton>
<PageButton
onClick={() => handlePageClick(totalPages)}
disabled={currentPage === totalPages}
>
»
</PageButton>
</PaginationContainer>
);
}

View File

@@ -0,0 +1,34 @@
'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',
},
};
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider refetchInterval={0}>
<ThemeProvider theme={theme}>
<GlobalStyles />
{children}
</ThemeProvider>
</SessionProvider>
);
}

View File

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

View File

@@ -0,0 +1,109 @@
'use client';
import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import Image from 'next/image';
import { Movie, TVShow } from '@/lib/api';
const ResultsContainer = styled.div`
max-height: 400px;
overflow-y: auto;
padding: 1rem;
`;
const ResultItem = styled(Link)`
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);
`;
const Type = styled.span`
font-size: 0.75rem;
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 1rem;
`;
interface SearchResultsProps {
results: (Movie | TVShow)[];
onItemClick: () => void;
}
export default function SearchResults({ results, onItemClick }: SearchResultsProps) {
const getYear = (date: string) => {
if (!date) return '';
return new Date(date).getFullYear();
};
const isMovie = (item: Movie | TVShow): item is Movie => {
return 'title' in item;
};
return (
<ResultsContainer>
{results.map((item) => (
<ResultItem
key={item.id}
href={isMovie(item) ? `/movie/${item.id}` : `/tv/${item.id}`}
onClick={onItemClick}
>
<PosterContainer>
<Image
src={item.poster_path
? `https://image.tmdb.org/t/p/w92${item.poster_path}`
: '/placeholder.png'}
alt={isMovie(item) ? item.title : item.name}
fill
style={{ objectFit: 'cover' }}
/>
</PosterContainer>
<ItemInfo>
<Title>
{isMovie(item) ? item.title : item.name}
<Type>{isMovie(item) ? 'Фильм' : 'Сериал'}</Type>
</Title>
<Year>
{getYear(isMovie(item) ? item.release_date : item.first_air_date)}
</Year>
</ItemInfo>
</ResultItem>
))}
</ResultsContainer>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useSettings } from '@/hooks/useSettings';
import styled from 'styled-components';
const Container = styled.div`
width: 100%;
max-width: 800px;
padding: 0 1rem;
`;
const Title = styled.h1`
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 2rem;
color: white;
`;
const PlayersList = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
`;
const PlayerCard = styled.div<{ $isSelected: boolean }>`
background: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid ${props => props.$isSelected ? '#2196f3' : 'transparent'};
&:hover {
background: rgba(255, 255, 255, 0.15);
}
`;
const PlayerName = styled.h2`
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: white;
`;
const PlayerDescription = styled.p`
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
`;
const SaveButton = styled.button`
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #2196f3;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #1976d2;
}
`;
export default function SettingsContent() {
const { settings, updateSettings } = useSettings();
const players = [
{
id: 'alloha',
name: 'Alloha',
description: 'Основной плеер с высоким качеством',
},
{
id: 'collaps',
name: 'Collaps',
description: 'Альтернативный плеер с хорошей стабильностью',
},
{
id: 'lumex',
name: 'Lumex',
description: 'Плеер с возможностью скачивания фильмов',
},
];
const handlePlayerSelect = (playerId: string) => {
updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' });
};
return (
<Container>
<Title>Настройки плеера</Title>
<PlayersList>
{players.map((player) => (
<PlayerCard
key={player.id}
$isSelected={settings.defaultPlayer === player.id}
onClick={() => handlePlayerSelect(player.id)}
>
<PlayerName>{player.name}</PlayerName>
<PlayerDescription>{player.description}</PlayerDescription>
</PlayerCard>
))}
</PlayersList>
</Container>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import GlobalStyles from '@/styles/GlobalStyles';
export default function StyleProvider({ children }: { children: React.ReactNode }) {
return (
<>
<GlobalStyles />
{children}
</>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import React, { useRef, useState } from 'react';
import styled from 'styled-components';
const Container = styled.div`
display: flex;
gap: 0.5rem;
justify-content: center;
margin: 1rem 0;
`;
const Input = styled.input`
width: 3rem;
height: 3.5rem;
padding: 0.5rem;
font-size: 1.5rem;
text-align: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #fff;
transition: all 0.2s;
&: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);
}
`;
interface Props {
length?: number;
onChange: (code: string) => void;
}
export function VerificationCodeInput({ length = 6, onChange }: Props) {
const [code, setCode] = useState<string[]>(Array(length).fill(''));
const inputs = useRef<(HTMLInputElement | null)[]>([]);
const processInput = (e: React.ChangeEvent<HTMLInputElement>, slot: number) => {
const num = e.target.value;
if (/[^0-9]/.test(num)) return;
const newCode = [...code];
newCode[slot] = num;
setCode(newCode);
const combinedCode = newCode.join('');
onChange(combinedCode);
if (slot !== length - 1 && num) {
inputs.current[slot + 1]?.focus();
}
};
const onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>, slot: number) => {
if (e.key === 'Backspace' && !code[slot] && slot !== 0) {
const newCode = [...code];
newCode[slot - 1] = '';
setCode(newCode);
inputs.current[slot - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const paste = e.clipboardData.getData('text');
const pasteNumbers = paste.match(/[0-9]/g);
if (!pasteNumbers) return;
const newCode = [...code];
pasteNumbers.forEach((num, i) => {
if (i >= length) return;
newCode[i] = num;
inputs.current[i]?.value = num;
});
setCode(newCode);
onChange(newCode.join(''));
inputs.current[Math.min(pasteNumbers.length, length - 1)]?.focus();
};
return (
<Container>
{code.map((num, idx) => (
<Input
key={idx}
type="text"
inputMode="numeric"
maxLength={1}
value={num}
autoFocus={!code[0].length && idx === 0}
onChange={(e) => processInput(e, idx)}
onKeyUp={(e) => onKeyUp(e, idx)}
onPaste={handlePaste}
ref={(ref) => inputs.current[idx] = ref}
/>
))}
</Container>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { debounce } from 'lodash';
interface Movie {
id: number;
title: string;
overview: string;
release_date: string;
vote_average: number;
poster_path: string | null;
genre_ids: number[];
}
export default function MovieSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Movie[]>([]);
const [loading, setLoading] = useState(false);
const searchMovies = debounce(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
try {
setLoading(true);
const response = await fetch(
`/api/movies/search?query=${encodeURIComponent(query)}`
);
const data = await response.json();
setSearchResults(data.results || []);
} catch (error) {
console.error('Error searching movies:', error);
} finally {
setLoading(false);
}
}, 500);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
searchMovies(query);
};
return (
<div>
<div className="relative mb-4">
<input
type="text"
value={searchQuery}
onChange={handleSearch}
placeholder="Поиск фильмов..."
className="w-full px-4 py-2 bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{loading && (
<div className="absolute right-3 top-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
)}
</div>
{searchResults.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{searchResults.map((movie) => (
<div
key={movie.id}
className="bg-gray-800 rounded-lg overflow-hidden"
>
<div className="aspect-w-2 aspect-h-3">
<img
src={
movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
: '/placeholder.jpg'
}
alt={movie.title}
className="object-cover w-full h-full"
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-lg mb-2">{movie.title}</h3>
<p className="text-sm text-gray-400 mb-4">
{new Date(movie.release_date).getFullYear()} {movie.vote_average.toFixed(1)}
</p>
<p className="text-sm text-gray-400 line-clamp-3 mb-4">
{movie.overview}
</p>
</div>
</div>
))}
</div>
)}
</div>
);
}