indexer/app/page.tsx

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>
);
}