mirror of
https://gitlab.com/foxixus/neomovies.git
synced 2025-10-28 01:48:50 +05:00
Release 2.3.2
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
|
import Script from 'next/script';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { ClientLayout } from '@/components/ClientLayout';
|
import { ClientLayout } from '@/components/ClientLayout';
|
||||||
import { Providers } from '@/components/Providers';
|
import { Providers } from '@/components/Providers';
|
||||||
@@ -22,8 +23,44 @@ export default function RootLayout({
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta name="darkreader-lock" />
|
<meta name="darkreader-lock" />
|
||||||
|
|
||||||
|
{/* Google tag (gtag.js) */}
|
||||||
|
<Script
|
||||||
|
strategy="afterInteractive"
|
||||||
|
src="https://www.googletagmanager.com/gtag/js?id=G-B4DPLCBCH4"
|
||||||
|
/>
|
||||||
|
<Script id="gtag-init" strategy="afterInteractive">
|
||||||
|
{`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-B4DPLCBCH4');
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
|
||||||
|
{/* Google Tag Manager */}
|
||||||
|
<Script id="google-tag-manager" strategy="afterInteractive">
|
||||||
|
{`
|
||||||
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||||
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||||
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||||
|
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||||
|
})(window,document,'script','dataLayer','GTM-57L8PCLP');
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
{/* End Google Tag Manager */}
|
||||||
</head>
|
</head>
|
||||||
<body className={inter.className} suppressHydrationWarning>
|
<body className={inter.className} suppressHydrationWarning>
|
||||||
|
{/* Google Tag Manager (noscript) */}
|
||||||
|
<noscript>
|
||||||
|
<iframe
|
||||||
|
src="https://www.googletagmanager.com/ns.html?id=GTM-57L8PCLP"
|
||||||
|
height="0"
|
||||||
|
width="0"
|
||||||
|
style={{ display: 'none', visibility: 'hidden' }}
|
||||||
|
></iframe>
|
||||||
|
</noscript>
|
||||||
|
{/* End Google Tag Manager (noscript) */}
|
||||||
<Providers>
|
<Providers>
|
||||||
<ClientLayout>{children}</ClientLayout>
|
<ClientLayout>{children}</ClientLayout>
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { moviesAPI } from '@/lib/neoApi';
|
|||||||
import { getImageUrl } from '@/lib/neoApi';
|
import { getImageUrl } from '@/lib/neoApi';
|
||||||
import type { MovieDetails } from '@/lib/api';
|
import type { MovieDetails } from '@/lib/api';
|
||||||
import MoviePlayer from '@/components/MoviePlayer';
|
import MoviePlayer from '@/components/MoviePlayer';
|
||||||
|
import TorrentSelector from '@/components/TorrentSelector';
|
||||||
import FavoriteButton from '@/components/FavoriteButton';
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
import Reactions from '@/components/Reactions';
|
import Reactions from '@/components/Reactions';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
@@ -142,6 +143,10 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp
|
|||||||
poster={movie.poster_path || ''}
|
poster={movie.poster_path || ''}
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
/>
|
/>
|
||||||
|
<TorrentSelector
|
||||||
|
imdbId={imdbId}
|
||||||
|
type="movie"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
{activeTab === 'popular' && (
|
{activeTab === 'popular' && (
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { tvAPI } from '@/lib/api';
|
|||||||
import { getImageUrl } from '@/lib/neoApi';
|
import { getImageUrl } from '@/lib/neoApi';
|
||||||
import type { TVShowDetails } from '@/lib/api';
|
import type { TVShowDetails } from '@/lib/api';
|
||||||
import MoviePlayer from '@/components/MoviePlayer';
|
import MoviePlayer from '@/components/MoviePlayer';
|
||||||
|
import TorrentSelector from '@/components/TorrentSelector';
|
||||||
import FavoriteButton from '@/components/FavoriteButton';
|
import FavoriteButton from '@/components/FavoriteButton';
|
||||||
import Reactions from '@/components/Reactions';
|
import Reactions from '@/components/Reactions';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
@@ -145,6 +146,11 @@ export default function TVContent({ showId, initialShow }: TVContentProps) {
|
|||||||
poster={show.poster_path || ''}
|
poster={show.poster_path || ''}
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
/>
|
/>
|
||||||
|
<TorrentSelector
|
||||||
|
imdbId={imdbId}
|
||||||
|
type="tv"
|
||||||
|
totalSeasons={show.number_of_seasons}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
197
src/components/TorrentSelector.tsx
Normal file
197
src/components/TorrentSelector.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2, AlertTriangle, Copy, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Torrent {
|
||||||
|
magnet: string;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
quality?: string;
|
||||||
|
seeders?: number;
|
||||||
|
size_gb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedTorrents {
|
||||||
|
[quality: string]: Torrent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeasonGroupedTorrents {
|
||||||
|
[season: string]: GroupedTorrents;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TorrentSelectorProps {
|
||||||
|
imdbId: string | null;
|
||||||
|
type: 'movie' | 'tv';
|
||||||
|
totalSeasons?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TorrentSelector({ imdbId, type, totalSeasons }: TorrentSelectorProps) {
|
||||||
|
const [torrents, setTorrents] = useState<Torrent[] | null>(null);
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(type === 'movie' ? 1 : null);
|
||||||
|
const [selectedMagnet, setSelectedMagnet] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
// Для TV показов автоматически выбираем первый сезон
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === 'tv' && totalSeasons && totalSeasons > 0 && !selectedSeason) {
|
||||||
|
setSelectedSeason(1);
|
||||||
|
}
|
||||||
|
}, [type, totalSeasons, selectedSeason]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imdbId) return;
|
||||||
|
|
||||||
|
// Для фильмов загружаем сразу
|
||||||
|
if (type === 'movie') {
|
||||||
|
fetchTorrents();
|
||||||
|
}
|
||||||
|
// Для TV показов загружаем только когда выбран сезон
|
||||||
|
else if (type === 'tv' && selectedSeason) {
|
||||||
|
fetchTorrents();
|
||||||
|
}
|
||||||
|
}, [imdbId, type, selectedSeason]);
|
||||||
|
|
||||||
|
const fetchTorrents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedMagnet(null);
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (!apiUrl) {
|
||||||
|
throw new Error('API URL не настроен');
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = `${apiUrl}/torrents/search/${imdbId}?type=${type}`;
|
||||||
|
|
||||||
|
if (type === 'tv' && selectedSeason) {
|
||||||
|
url += `&season=${selectedSeason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('API URL:', url, 'IMDB:', imdbId, 'season:', selectedSeason);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch torrents');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.total === 0) {
|
||||||
|
setError('Торренты не найдены.');
|
||||||
|
} else {
|
||||||
|
setTorrents(data.results as Torrent[]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError('Не удалось загрузить список торрентов.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQualitySelect = (torrent: Torrent) => {
|
||||||
|
setSelectedMagnet(torrent.magnet);
|
||||||
|
setIsCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!selectedMagnet) return;
|
||||||
|
navigator.clipboard.writeText(selectedMagnet);
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<span>Загрузка торрентов...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-100 p-3 text-sm text-red-700">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!torrents) return null;
|
||||||
|
|
||||||
|
const renderTorrentButtons = (list: Torrent[]) => {
|
||||||
|
if (!list?.length) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Торрентов для выбранного сезона нет.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.map(torrent => {
|
||||||
|
const size = torrent.size_gb;
|
||||||
|
const label = torrent.title || torrent.name || 'Раздача';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={torrent.magnet}
|
||||||
|
onClick={() => handleQualitySelect(torrent)}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full items-center text-left px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate whitespace-nowrap overflow-hidden">{label}</span>
|
||||||
|
{size !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground">{size.toFixed(2)} GB</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{type === 'tv' && totalSeasons && totalSeasons > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Сезоны</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.from({ length: totalSeasons }, (_, i) => i + 1).map(season => (
|
||||||
|
<Button
|
||||||
|
key={season}
|
||||||
|
onClick={() => {setSelectedSeason(season); setSelectedMagnet(null);}}
|
||||||
|
variant={selectedSeason === season ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
Сезон {season}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSeason && torrents && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Раздачи</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderTorrentButtons(torrents)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMagnet && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Magnet-ссылка</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 rounded-md border bg-secondary/50 px-3 py-2 text-sm">
|
||||||
|
{selectedMagnet}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCopy} size="icon" variant="outline">
|
||||||
|
{isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
export const generateVerificationToken = () => {
|
export const generateVerificationToken = () => {
|
||||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user