382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import Link from "next/link";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCaption,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogClose,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
} from "@/components/ui/carousel";
|
|
import Autoplay from "embla-carousel-autoplay";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import React, { useState, useRef, useMemo } from "react";
|
|
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
|
|
|
// Mock data structure
|
|
interface NzbItem {
|
|
id: string;
|
|
name: string;
|
|
size: string;
|
|
age: string;
|
|
category: string;
|
|
downloads: number;
|
|
metadata: {
|
|
description: string;
|
|
groups: string[];
|
|
files: string[];
|
|
};
|
|
}
|
|
|
|
// Mock data structure for Carousel items
|
|
interface FeaturedItem {
|
|
id: string;
|
|
title: string;
|
|
category: string;
|
|
imageUrl?: string; // Optional: direct image URL or use a placeholder
|
|
description: string;
|
|
rating: string; // e.g., "IMDb: 8.5/10"
|
|
}
|
|
|
|
// Placeholder SVG for Carousel Item Image
|
|
const PlaceholderCarouselImage = ({ text }: { text: string }) => (
|
|
<div className="w-full h-64 bg-muted flex items-center justify-center rounded-md">
|
|
<span className="text-xl text-muted-foreground">{text}</span>
|
|
</div>
|
|
);
|
|
|
|
// Helper function to parse size string (e.g., "1.2 GB") to bytes
|
|
const parseSizeToBytes = (sizeStr: string): number => {
|
|
const parts = sizeStr.toLowerCase().split(" ");
|
|
if (parts.length !== 2) return 0;
|
|
const value = parseFloat(parts[0]);
|
|
const unit = parts[1];
|
|
if (isNaN(value)) return 0;
|
|
|
|
if (unit.startsWith("kb")) return value * 1024;
|
|
if (unit.startsWith("mb")) return value * 1024 * 1024;
|
|
if (unit.startsWith("gb")) return value * 1024 * 1024 * 1024;
|
|
if (unit.startsWith("tb")) return value * 1024 * 1024 * 1024 * 1024;
|
|
return value; // Assuming bytes if no unit or unknown unit
|
|
};
|
|
|
|
// Helper function to parse age string (e.g., "3 days", "5 hours") to a consistent unit (e.g., minutes)
|
|
const parseAgeToMinutes = (ageStr: string): number => {
|
|
const parts = ageStr.toLowerCase().split(" ");
|
|
if (parts.length !== 2) return 0;
|
|
const value = parseInt(parts[0]);
|
|
const unit = parts[1];
|
|
if (isNaN(value)) return 0;
|
|
|
|
if (unit.startsWith("minute")) return value;
|
|
if (unit.startsWith("hour")) return value * 60;
|
|
if (unit.startsWith("day")) return value * 60 * 24;
|
|
if (unit.startsWith("week")) return value * 60 * 24 * 7;
|
|
if (unit.startsWith("month")) return value * 60 * 24 * 30; // Approximate
|
|
if (unit.startsWith("year")) return value * 60 * 24 * 365; // Approximate
|
|
return 0;
|
|
};
|
|
|
|
export default function IndexerPage() {
|
|
const categories = ["All", "Movies", "Series", "Anime", "Music"];
|
|
const [selectedItem, setSelectedItem] = useState<NzbItem | null>(null);
|
|
const [activeTab, setActiveTab] = useState<string>("All");
|
|
|
|
const [sortColumn, setSortColumn] = useState<keyof NzbItem | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
|
|
// Autoplay plugin ref
|
|
const autoplayPlugin = useRef(
|
|
Autoplay({ delay: 10000, stopOnInteraction: true })
|
|
);
|
|
|
|
const mockFeaturedData: FeaturedItem[] = [
|
|
{
|
|
id: "feat1",
|
|
title: "Blockbuster Movie Premiere",
|
|
category: "Movies",
|
|
description: "The latest action-packed thriller hits the screens! Don't miss out.",
|
|
rating: "IMDb: 9.2/10",
|
|
},
|
|
{
|
|
id: "feat2",
|
|
title: "New Hit Series - Episode 1",
|
|
category: "Series",
|
|
description: "A gripping new drama series that will keep you on the edge of your seat.",
|
|
rating: "Rotten Tomatoes: 95%",
|
|
},
|
|
{
|
|
id: "feat3",
|
|
title: "Must-Watch Anime Film",
|
|
category: "Anime",
|
|
description: "Critically acclaimed anime feature with stunning visuals.",
|
|
rating: "MyAnimeList: 8.9/10",
|
|
},
|
|
];
|
|
|
|
const mockNzbData: NzbItem[] = [
|
|
{
|
|
id: "1",
|
|
name: "Example Movie Title",
|
|
size: "2.5 GB",
|
|
age: "1 day",
|
|
category: "Movies",
|
|
downloads: 1250,
|
|
metadata: {
|
|
description: "A fantastic movie about something exciting.",
|
|
groups: ["alt.binaries.movies.hd"],
|
|
files: ["movie_part1.rar", "movie_part2.rar"],
|
|
},
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "Awesome Series S01E01",
|
|
size: "500 MB",
|
|
age: "5 hours",
|
|
category: "Series",
|
|
downloads: 340,
|
|
metadata: {
|
|
description: "First episode of an awesome new series.",
|
|
groups: ["alt.binaries.tv"],
|
|
files: ["series_s01e01.mkv"],
|
|
},
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "Cool Anime Movie",
|
|
size: "1.2 GB",
|
|
age: "3 days",
|
|
category: "Anime",
|
|
downloads: 780,
|
|
metadata: {
|
|
description: "A visually stunning anime film.",
|
|
groups: ["alt.binaries.anime"],
|
|
files: ["anime.movie.mkv"],
|
|
},
|
|
},
|
|
{
|
|
id: "4",
|
|
name: "Great Music Album",
|
|
size: "300 MB",
|
|
age: "10 days",
|
|
category: "Music",
|
|
downloads: 50,
|
|
metadata: {
|
|
description: "An album by a popular artist.",
|
|
groups: ["alt.binaries.music.mp3"],
|
|
files: ["track01.mp3", "track02.mp3", "cover.jpg"],
|
|
},
|
|
},
|
|
];
|
|
|
|
const handleSort = (column: keyof NzbItem) => {
|
|
if (sortColumn === column) {
|
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortColumn(column);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
const sortedData = useMemo(() => {
|
|
let dataToFilter = activeTab === "All" ? mockNzbData : mockNzbData.filter(item => item.category === activeTab);
|
|
if (!sortColumn) return dataToFilter;
|
|
|
|
const currentSortColumn = sortColumn as keyof NzbItem;
|
|
|
|
return [...dataToFilter].sort((a, b) => {
|
|
const aVal = a[currentSortColumn];
|
|
const bVal = b[currentSortColumn];
|
|
|
|
let comparison = 0;
|
|
|
|
if (currentSortColumn === 'size') {
|
|
comparison = parseSizeToBytes(aVal as string) - parseSizeToBytes(bVal as string);
|
|
} else if (currentSortColumn === 'age') {
|
|
comparison = parseAgeToMinutes(aVal as string) - parseAgeToMinutes(bVal as string);
|
|
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
comparison = aVal - bVal;
|
|
} else if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
comparison = aVal.localeCompare(bVal);
|
|
}
|
|
|
|
return sortDirection === 'asc' ? comparison : -comparison;
|
|
});
|
|
}, [mockNzbData, activeTab, sortColumn, sortDirection]);
|
|
|
|
const getSortIcon = (column: keyof NzbItem) => {
|
|
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
|
if (sortDirection === 'asc') return <ArrowUp className="ml-2 h-4 w-4 text-primary" />;
|
|
return <ArrowDown className="ml-2 h-4 w-4 text-primary" />;
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto p-4 py-8 md:py-12 space-y-8">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-3xl font-bold">NZB Indexer</h1>
|
|
<div className="space-x-2">
|
|
<Link href="/news" passHref>
|
|
<Button variant="ghost">News</Button>
|
|
</Link>
|
|
<Link href="/upgrade" passHref>
|
|
<Button variant="ghost">Upgrade</Button>
|
|
</Link>
|
|
<Link href="/settings" passHref>
|
|
<Button variant="ghost">Settings</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Featured Carousel Section */}
|
|
<div className="space-y-4">
|
|
<h2
|
|
className="text-2xl font-semibold tracking-tight text-primary drop-shadow-[0_2px_3px_hsl(var(--primary)/0.5)]"
|
|
>
|
|
Newest Uploads
|
|
</h2>
|
|
<Carousel
|
|
className="w-full"
|
|
opts={{ loop: true }}
|
|
plugins={[autoplayPlugin.current]}
|
|
onMouseEnter={autoplayPlugin.current.stop}
|
|
onMouseLeave={autoplayPlugin.current.reset}
|
|
>
|
|
<CarouselContent>
|
|
{mockFeaturedData.map((featuredItem) => (
|
|
<CarouselItem key={featuredItem.id}>
|
|
<Card className="overflow-hidden">
|
|
<CardContent className="flex flex-col md:flex-row items-center p-0">
|
|
<div className="w-full md:w-1/3 h-64 md:h-auto">
|
|
<PlaceholderCarouselImage text={featuredItem.title} />
|
|
</div>
|
|
<div className="p-6 space-y-3 flex-1">
|
|
<h3 className="text-xl font-semibold">{featuredItem.title}</h3>
|
|
<p className="text-sm text-muted-foreground">{featuredItem.description}</p>
|
|
<div className="flex justify-between items-center pt-2">
|
|
<span className="text-sm font-medium text-primary">{featuredItem.rating}</span>
|
|
<Button variant="secondary" size="sm">View Details</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
</div>
|
|
|
|
<Tabs defaultValue="All" className="w-full" onValueChange={setActiveTab}>
|
|
<TabsList className="mb-4">
|
|
{categories.map((category) => (
|
|
<TabsTrigger key={category} value={category}>
|
|
{category}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
|
|
<Table>
|
|
<TableCaption>
|
|
{activeTab === "All"
|
|
? "A list of all NZBs."
|
|
: `Results for ${activeTab}.`}
|
|
{sortColumn && ` Sorted by ${sortColumn} (${sortDirection === 'asc' ? 'ascending' : 'descending'}).`}
|
|
</TableCaption>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[35%] cursor-pointer hover:bg-muted/50 transition-colors" onClick={() => handleSort('name')}>
|
|
<div className="flex items-center">Name {getSortIcon('name')}</div>
|
|
</TableHead>
|
|
<TableHead className="cursor-pointer hover:bg-muted/50 transition-colors" onClick={() => handleSort('size')}>
|
|
<div className="flex items-center">Size {getSortIcon('size')}</div>
|
|
</TableHead>
|
|
<TableHead className="cursor-pointer hover:bg-muted/50 transition-colors" onClick={() => handleSort('age')}>
|
|
<div className="flex items-center">Age {getSortIcon('age')}</div>
|
|
</TableHead>
|
|
<TableHead className="cursor-pointer hover:bg-muted/50 transition-colors" onClick={() => handleSort('downloads')}>
|
|
<div className="flex items-center">Downloads {getSortIcon('downloads')}</div>
|
|
</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedData.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-medium">{item.name}</TableCell>
|
|
<TableCell>{item.size}</TableCell>
|
|
<TableCell>{item.age}</TableCell>
|
|
<TableCell>{item.downloads.toLocaleString()}</TableCell>
|
|
<TableCell className="text-right space-x-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedItem(item)}>
|
|
Info
|
|
</Button>
|
|
<Button variant="outline" size="sm">
|
|
Download
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Tabs>
|
|
|
|
{/* Dialog for NZB Info - controlled by selectedItem state */}
|
|
{selectedItem && (
|
|
<Dialog open={!!selectedItem} onOpenChange={(isOpen) => !isOpen && setSelectedItem(null)}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedItem.name}</DialogTitle>
|
|
<DialogDescription>
|
|
Category: {selectedItem.category} | Size: {selectedItem.size} | Age: {selectedItem.age} | Downloads: {selectedItem.downloads.toLocaleString()}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4 space-y-2">
|
|
<h4 className="font-semibold">Description:</h4>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedItem.metadata.description}
|
|
</p>
|
|
<h4 className="font-semibold">Groups:</h4>
|
|
<ul className="list-disc list-inside text-sm text-muted-foreground">
|
|
{selectedItem.metadata.groups.map((group) => (
|
|
<li key={group}>{group}</li>
|
|
))}
|
|
</ul>
|
|
<h4 className="font-semibold">Files:</h4>
|
|
<ul className="list-disc list-inside text-sm text-muted-foreground">
|
|
{selectedItem.metadata.files.map((file) => (
|
|
<li key={file}>{file}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<DialogFooter className="sm:justify-start">
|
|
<DialogClose asChild>
|
|
<Button type="button" variant="secondary">
|
|
Close
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|