mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 18:08:49 +05:00
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:
42
src/components/AppLayout.tsx
Normal file
42
src/components/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ClientLayout.tsx
Normal file
29
src/components/ClientLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/DarkReaderFix.tsx
Normal file
13
src/components/DarkReaderFix.tsx
Normal 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;
|
||||
}
|
||||
17
src/components/GlassCard.tsx
Normal file
17
src/components/GlassCard.tsx
Normal 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;
|
||||
18
src/components/GoogleIcon.tsx
Normal file
18
src/components/GoogleIcon.tsx
Normal 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;
|
||||
5
src/components/Icons/HeartIcon.tsx
Normal file
5
src/components/Icons/HeartIcon.tsx
Normal 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>
|
||||
);
|
||||
80
src/components/Icons/Icons.tsx
Normal file
80
src/components/Icons/Icons.tsx
Normal 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>
|
||||
);
|
||||
18
src/components/Icons/PlayIcon.tsx
Normal file
18
src/components/Icons/PlayIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/LayoutContent.tsx
Normal file
28
src/components/LayoutContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/MenuItem.tsx
Normal file
78
src/components/MenuItem.tsx
Normal 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;
|
||||
}
|
||||
93
src/components/MovieCard.tsx
Normal file
93
src/components/MovieCard.tsx
Normal 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;
|
||||
`;
|
||||
234
src/components/MoviePlayer.tsx
Normal file
234
src/components/MoviePlayer.tsx
Normal 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
406
src/components/Navbar.tsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/components/Notification.tsx
Normal file
58
src/components/Notification.tsx
Normal 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>;
|
||||
}
|
||||
88
src/components/PageLayout.tsx
Normal file
88
src/components/PageLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/Pagination.tsx
Normal file
106
src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/Providers.tsx
Normal file
34
src/components/Providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/components/SearchModal.tsx
Normal file
177
src/components/SearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/SearchResults.tsx
Normal file
109
src/components/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/SettingsContent.tsx
Normal file
109
src/components/SettingsContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/StyleProvider.tsx
Normal file
12
src/components/StyleProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import GlobalStyles from '@/styles/GlobalStyles';
|
||||
|
||||
export default function StyleProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/VerificationCodeInput.tsx
Normal file
104
src/components/VerificationCodeInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/admin/MovieSearch.tsx
Normal file
97
src/components/admin/MovieSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user