mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 18:08:49 +05:00
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:
101
src/components/FavoriteButton.tsx
Normal file
101
src/components/FavoriteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user