Update 35 files

- /src/api.ts
- /src/lib/utils.ts
- /src/lib/neoApi.ts
- /src/lib/mongodb.ts
- /src/lib/favoritesApi.ts
- /src/lib/models/Favorite.ts
- /src/hooks/useTMDBMovies.ts
- /src/hooks/useImageLoader.ts
- /src/hooks/useMovies.ts
- /src/types/movie.ts
- /src/components/SearchResults.tsx
- /src/components/SettingsContent.tsx
- /src/components/MovieCard.tsx
- /src/components/FavoriteButton.tsx
- /src/components/admin/MovieSearch.tsx
- /src/app/page.tsx
- /src/app/movie/[id]/page.tsx
- /src/app/movie/[id]/MovieContent.tsx
- /src/app/api/movies/upcoming/route.ts
- /src/app/api/movies/search/route.ts
- /src/app/api/movies/top-rated/route.ts
- /src/app/api/movies/[id]/route.ts
- /src/app/api/movies/popular/route.ts
- /src/app/api/favorites/route.ts
- /src/app/api/favorites/check/[mediaId]/route.ts
- /src/app/api/favorites/[mediaId]/route.ts
- /src/app/tv/[id]/TVShowContent.tsx
- /src/app/tv/[id]/TVShowPage.tsx
- /src/app/tv/[id]/page.tsx
- /src/app/favorites/page.tsx
- /src/configs/auth.ts
- /next.config.js
- /package.json
- /README.md
- /package-lock.json
This commit is contained in:
2025-01-05 01:43:34 +00:00
parent 3c3f58c7d3
commit 0aa6fb6038
35 changed files with 1656 additions and 548 deletions

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { favoritesAPI } from '@/lib/favoritesApi';
import { Heart } from 'lucide-react';
import styled from 'styled-components';
import { toast } from 'react-hot-toast';
const Button = styled.button<{ $isFavorite: boolean }>`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'};
border: none;
color: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${props => props.$isFavorite ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
}
svg {
width: 1.2rem;
height: 1.2rem;
fill: ${props => props.$isFavorite ? '#ff4444' : 'none'};
stroke: ${props => props.$isFavorite ? '#ff4444' : '#fff'};
transition: all 0.2s;
}
`;
interface FavoriteButtonProps {
mediaId: string | number;
mediaType: 'movie' | 'tv';
title: string;
posterPath: string | null;
className?: string;
}
export default function FavoriteButton({ mediaId, mediaType, title, posterPath, className }: FavoriteButtonProps) {
const { data: session, status } = useSession();
const [isFavorite, setIsFavorite] = useState(false);
// Преобразуем mediaId в строку для сравнения
const mediaIdString = mediaId.toString();
useEffect(() => {
const checkFavorite = async () => {
// Проверяем только если пользователь авторизован
if (status !== 'authenticated' || !session?.user?.email) return;
try {
const response = await favoritesAPI.getFavorites();
const favorites = response.data;
const isFav = favorites.some(
fav => fav.mediaId === mediaIdString && fav.mediaType === mediaType
);
setIsFavorite(isFav);
} catch (error) {
console.error('Error checking favorite status:', error);
}
};
checkFavorite();
}, [session?.user?.email, mediaIdString, mediaType, status]);
const toggleFavorite = async () => {
if (!session?.user?.email) {
toast.error('Для добавления в избранное необходимо авторизоваться');
return;
}
try {
if (isFavorite) {
await favoritesAPI.removeFavorite(mediaIdString, mediaType);
toast.success('Удалено из избранного');
setIsFavorite(false);
} else {
await favoritesAPI.addFavorite({
mediaId: mediaIdString,
mediaType,
title,
posterPath: posterPath || undefined,
});
toast.success('Добавлено в избранное');
setIsFavorite(true);
}
} catch (error) {
console.error('Error toggling favorite:', error);
toast.error('Произошла ошибка');
}
};
return (
<Button type="button" onClick={toggleFavorite} $isFavorite={isFavorite} className={className}>
<Heart />
{isFavorite ? 'В избранном' : 'В избранное'}
</Button>
);
}

View File

@@ -1,47 +1,61 @@
'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import styled from 'styled-components';
import { Movie } from '@/types/movie';
import { formatDate } from '@/lib/utils';
import { useImageLoader } from '@/hooks/useImageLoader';
interface MovieCardProps {
movie: Movie;
priority?: boolean;
}
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';
export default function MovieCard({ movie, priority = false }: MovieCardProps) {
const { imageUrl, isLoading } = useImageLoader(movie.poster_path, 'w342');
return (
<Card href={`/movie/${movie.id}`}>
<PosterWrapper>
<Poster
src={posterUrl}
alt={movie.title}
width={200}
height={300}
style={{ objectFit: 'cover' }}
/>
{isLoading ? (
<div className="flex h-full w-full items-center justify-center bg-gray-700">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-white" />
</div>
) : imageUrl ? (
<Poster
src={imageUrl}
alt={movie.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={priority}
className="object-cover transition-opacity duration-300 group-hover:opacity-75"
unoptimized // Отключаем оптимизацию Next.js, так как используем CDN
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-700 text-gray-400">
No Image
</div>
)}
<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>
<Year>{formatDate(movie.release_date)}</Year>
</Content>
</Card>
);
}
const getRatingColor = (rating: number) => {
if (rating >= 7) return '#4CAF50';
if (rating >= 5) return '#FFC107';
return '#F44336';
};
const Card = styled(Link)`
position: relative;
border-radius: 16px;

View File

@@ -4,6 +4,7 @@ import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import Image from 'next/image';
import { getImageUrl } from '@/lib/neoApi';
import { Movie, TVShow } from '@/lib/api';
const ResultsContainer = styled.div`
@@ -12,7 +13,7 @@ const ResultsContainer = styled.div`
padding: 1rem;
`;
const ResultItem = styled(Link)`
const ResultItem = styled.div`
display: flex;
padding: 0.75rem;
gap: 1rem;
@@ -53,56 +54,43 @@ const Year = styled.span`
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;
}
const getYear = (date: string | undefined | null): string => {
if (!date) return '';
const year = date.split(' ')[2]; // Получаем год из формата "DD месяц YYYY г."
return year ? year : '';
};
export default function SearchResults({ results, onItemClick }: SearchResultsProps) {
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}`}
<Link
key={`${item.id}-${item.media_type}`}
href={`/${item.media_type}/${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>
<ResultItem>
<PosterContainer>
<Image
src={item.poster_path ? getImageUrl(item.poster_path, 'w92') : '/images/placeholder.jpg'}
alt={item.title || item.name}
width={46}
height={69}
/>
</PosterContainer>
<ItemInfo>
<Title>{item.title || item.name}</Title>
<Year>
{getYear(item.release_date || item.first_air_date)}
</Year>
</ItemInfo>
</ResultItem>
</Link>
))}
</ResultsContainer>
);

View File

@@ -2,6 +2,7 @@
import { useSettings } from '@/hooks/useSettings';
import styled from 'styled-components';
import { useRouter } from 'next/navigation';
const Container = styled.div`
width: 100%;
@@ -66,6 +67,7 @@ const SaveButton = styled.button`
export default function SettingsContent() {
const { settings, updateSettings } = useSettings();
const router = useRouter();
const players = [
{
@@ -87,6 +89,8 @@ export default function SettingsContent() {
const handlePlayerSelect = (playerId: string) => {
updateSettings({ defaultPlayer: playerId as 'alloha' | 'collaps' | 'lumex' });
// Возвращаемся на предыдущую страницу
window.history.back();
};
return (

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { debounce } from 'lodash';
import { getImageUrl } from '@/lib/neoApi';
interface Movie {
id: number;
@@ -13,6 +14,82 @@ interface Movie {
genre_ids: number[];
}
interface MovieCardProps {
children: React.ReactNode;
}
const MovieCard: React.FC<MovieCardProps> = ({ children }) => {
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{children}
</div>
);
};
interface PosterContainerProps {
children: React.ReactNode;
}
const PosterContainer: React.FC<PosterContainerProps> = ({ children }) => {
return (
<div className="aspect-w-2 aspect-h-3">
{children}
</div>
);
};
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
alt: string;
width: number;
height: number;
}
const Image: React.FC<ImageProps> = ({ src, alt, width, height, ...props }) => {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
className="object-cover w-full h-full"
{...props}
/>
);
};
interface MovieInfoProps {
children: React.ReactNode;
}
const MovieInfo: React.FC<MovieInfoProps> = ({ children }) => {
return (
<div className="p-4">
{children}
</div>
);
};
interface TitleProps {
children: React.ReactNode;
}
const Title: React.FC<TitleProps> = ({ children }) => {
return (
<h3 className="font-semibold text-lg mb-2">{children}</h3>
);
};
interface YearProps {
children: React.ReactNode;
}
const Year: React.FC<YearProps> = ({ children }) => {
return (
<p className="text-sm text-gray-400 mb-4">{children}</p>
);
};
export default function MovieSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Movie[]>([]);
@@ -64,31 +141,26 @@ export default function MovieSearch() {
{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'
}
<MovieCard key={movie.id}>
<PosterContainer>
<Image
src={movie.poster_path ? getImageUrl(movie.poster_path) : '/placeholder.jpg'}
alt={movie.title}
className="object-cover w-full h-full"
width={200}
height={300}
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-lg mb-2">{movie.title}</h3>
</PosterContainer>
<MovieInfo>
<Title>{movie.title}</Title>
<Year>{new Date(movie.release_date).getFullYear()}</Year>
<p className="text-sm text-gray-400 mb-4">
{new Date(movie.release_date).getFullYear()} {movie.vote_average.toFixed(1)}
{movie.vote_average.toFixed(1)}
</p>
<p className="text-sm text-gray-400 line-clamp-3 mb-4">
{movie.overview}
</p>
</div>
</div>
</MovieInfo>
</MovieCard>
))}
</div>
)}