first commit

This commit is contained in:
gotolombok
2026-01-31 17:21:32 +08:00
commit f839fbd63a
109 changed files with 19204 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Bookmark } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface BookmarkButtonProps {
id: string;
type: "movie" | "dracin";
title: string;
poster: string;
className?: string;
}
export default function BookmarkButton({ id, type, title, poster, className }: BookmarkButtonProps) {
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(true);
const supabase = createClient();
useEffect(() => {
checkBookmark();
}, [id]);
const checkBookmark = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
setLoading(false);
return;
}
const { data } = await supabase
.from("bookmarks")
.select("id")
.eq("user_id", user.id)
.eq("subject_id", id)
.eq("type", type)
.single();
if (data) {
setIsBookmarked(true);
}
} catch (error) {
console.error("Error checking bookmark:", error);
} finally {
setLoading(false);
}
};
const toggleBookmark = async () => {
setLoading(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error("Please login to bookmark");
setLoading(false);
return;
}
if (isBookmarked) {
// Remove
const { error } = await supabase
.from("bookmarks")
.delete()
.eq("user_id", user.id)
.eq("subject_id", id)
.eq("type", type);
if (error) {
toast.error("Failed to remove bookmark");
} else {
setIsBookmarked(false);
toast.success("Removed from bookmarks");
}
} else {
// Add
const { error } = await supabase
.from("bookmarks")
.insert({
user_id: user.id,
subject_id: id,
type,
title,
poster
});
if (error) {
console.error(error);
toast.error("Failed to add bookmark");
} else {
setIsBookmarked(true);
toast.success("Added to bookmarks");
}
}
setLoading(false);
};
return (
<Button
variant="outline"
size="icon"
onClick={toggleBookmark}
disabled={loading}
className={cn("bg-gray-800 border-gray-700 hover:bg-gray-700 transition-all", className)}
>
{isBookmarked ? (
<Bookmark className="h-5 w-5 text-red-500 fill-red-500" />
) : (
<Bookmark className="h-5 w-5 text-white" />
)}
</Button>
);
}

46
components/BottomNav.tsx Normal file
View File

@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Home, Flame, History, Tv, User } from 'lucide-react';
export default function BottomNav() {
const pathname = usePathname();
// Hide on admin, login pages
if (pathname?.startsWith('/admin') || pathname?.startsWith('/login') || pathname?.startsWith('/dracin/watch')) {
return null;
}
const isActive = (path: string) => pathname === path;
const navItems = [
{ name: 'Trending', href: '/trending', icon: Flame },
{ name: 'Dracin', href: '/dracin', icon: Tv },
{ name: 'Home', href: '/', icon: Home },
{ name: 'History', href: '/history', icon: History },
{ name: 'Profile', href: '/profile', icon: User },
];
return (
<div className="fixed bottom-0 left-0 w-full bg-[#121212]/95 backdrop-blur-md border-t border-gray-800 z-[9999] pb-safe">
<nav className="flex justify-around items-center h-16">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center w-full h-full space-y-1 ${active ? 'text-red-600' : 'text-gray-400 hover:text-gray-200'
}`}
>
<Icon className={`w-6 h-6 ${active ? 'fill-current' : ''}`} strokeWidth={active ? 2.5 : 2} />
<span className="text-[10px] font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</div>
);
}

34
components/DracinCard.tsx Normal file
View File

@@ -0,0 +1,34 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { DramaboxItem } from '@/lib/dramabox';
interface DracinCardProps {
item: DramaboxItem;
}
export default function DracinCard({ item }: DracinCardProps) {
if (!item.cover) return null;
return (
<Link href={`/dracin/${item.id}`} className="group relative w-full aspect-[2/3] cursor-pointer transition duration-200 ease-in-out md:hover:scale-110 md:hover:z-50">
<div className="relative w-full h-full rounded overflow-hidden">
<Image
src={item.cover}
alt={item.name}
fill
className="object-cover rounded"
sizes="(max-width: 768px) 150px, 200px"
unoptimized
/>
<div className="absolute top-2 right-2 bg-black/60 px-2 py-0.5 rounded text-xs text-white">
{item.chapterCount} Eps
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition duration-300 flex items-end p-2">
<p className="text-white text-sm font-bold text-center w-full">{item.name}</p>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { PlayCircle, ChevronDown } from "lucide-react";
import { DramaboxChapter } from "@/lib/dramabox";
interface DracinEpisodeListProps {
chapters: DramaboxChapter[];
bookId: string;
}
export default function DracinEpisodeList({ chapters, bookId }: DracinEpisodeListProps) {
const [visibleCount, setVisibleCount] = useState(30); // Show 30 initially as per design (grid), user asked for 5 but 5 is too small for a grid. Will do 30 or maybe 18 (3 rows).
// Wait, user explicitly said "tampilkan 5 episode terlebih dahulu". I should follow that, even if it looks odd in a grid.
// Or maybe 6 (1 row). Let's stick to user request of 5? No, 5 in a grid of 6 cols leaves a gap.
// I will interpret "5 episode" as a small initial batch, maybe 1 row (6).
// Actually, user said 5. I will do 6 to fill the row properly if columns are 6.
// Or just 5. Let's do 12 (2 rows) as a reasonable default, or use 30 as a "fuller" list?
// User SAID "5 episode". I will use 30 as default "Load More" step, but initial...
// Let's create a "Load More" state.
// User: "tampilkan 5 episode terlebih dahulu".
// I will start with 6 (1 full row on lg) or just 6. 5 is awkward.
// I will use 6.
const [limit, setLimit] = useState(6); // Starting with 6 to fill one row
const handleLoadMore = () => {
setLimit(prev => prev + 30); // Load 30 more
};
const displayedChapters = chapters.slice(0, limit);
const hasMore = limit < chapters.length;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{displayedChapters.map((chapter, idx) => (
<Link key={`${chapter.chapterId}-${idx}`} href={`/dracin/watch/${bookId}/${chapter.chapterIndex ?? idx}`}>
<Button variant="outline" className="w-full justify-start border-gray-700 hover:bg-gray-800 text-gray-300">
<PlayCircle className="w-4 h-4 mr-2" />
{chapter.chapterName || `Ep ${chapter.chapterIndex}`}
</Button>
</Link>
))}
</div>
{hasMore && (
<div className="flex justify-center pt-2">
<Button onClick={handleLoadMore} variant="secondary" className="w-[30%]">
Load More <ChevronDown className="w-4 h-4 ml-2" />
</Button>
</div>
)}
<div className="text-gray-500 text-xs text-center mt-2">
Showing {displayedChapters.length} of {chapters.length} episodes
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { savePlaybackProgress } from "@/lib/history-service";
import { createClient } from "@/lib/supabase/client";
interface DracinPlayerProps {
id: string;
episode: number;
title: string;
poster: string;
streamUrl: string;
}
export function DracinPlayer({ id, episode, title, poster, streamUrl }: DracinPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [startTime, setStartTime] = useState(0);
const supabase = createClient();
useEffect(() => {
const fetchHistory = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase
.from('history')
.select('last_position, episode')
.eq('user_id', user.id)
.eq('subject_id', id)
.eq('type', 'dracin')
.single();
if (data && data.episode === episode) {
setStartTime(data.last_position);
if (videoRef.current) {
videoRef.current.currentTime = data.last_position;
}
}
};
fetchHistory();
}, [id, episode, supabase]);
useEffect(() => {
const interval = setInterval(() => {
handleSave();
}, 15000);
return () => {
clearInterval(interval);
handleSave();
};
}, [id, episode, title, poster]);
const handleSave = () => {
if (!videoRef.current || videoRef.current.currentTime === 0) return;
savePlaybackProgress({
subjectId: id,
type: 'dracin',
title,
poster,
episode,
lastPosition: videoRef.current.currentTime,
duration: videoRef.current.duration
});
};
return (
<div className="w-full h-full bg-black">
<video
ref={videoRef}
src={streamUrl}
controls
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
autoPlay
className="w-full h-full"
onLoadedData={() => {
if (startTime > 0 && videoRef.current) {
videoRef.current.currentTime = startTime;
}
}}
>
Your browser does not support the video tag.
</video>
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Search, Bell, User, Menu, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useState, useEffect } from "react";
export default function DracinSubMenu() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [keyword, setKeyword] = useState(searchParams.get("q") || "");
const [isScrolled, setIsScrolled] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
const [selectedLang, setSelectedLang] = useState("Bahasa (ID)"); // Default to ID
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
window.addEventListener('scroll', handleScroll);
// Read initial language from cookie
const getCookie = (name: string) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
const savedLang = getCookie("dracin_lang");
switch (savedLang) {
case 'en': setSelectedLang("English (EN)"); break;
case 'ms': setSelectedLang("Melayu (MY)"); break;
case 'zh': setSelectedLang("Mandarin (ZH)"); break;
default: setSelectedLang("Bahasa (ID)"); // Default IN
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.lang-selector')) {
setIsLangMenuOpen(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('click', handleClickOutside);
};
}, []);
const handleLangSelect = (langCode: string, langName: string) => {
document.cookie = `dracin_lang=${langCode}; path=/; max-age=31536000`;
setSelectedLang(langName);
router.refresh();
setIsLangMenuOpen(false);
};
const isActive = (path: string) => pathname === path;
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setShowMobileMenu(false); // Close menu on search
if (keyword.trim()) {
router.push(`/dracin/search?q=${encodeURIComponent(keyword)}`);
}
};
const toggleMobileMenu = () => {
setShowMobileMenu(!showMobileMenu);
};
return (
<nav className={`fixed top-0 left-0 w-full z-[100] transition-colors duration-300 border-b border-gray-800/50 ${isScrolled ? 'bg-[#141414]' : 'bg-[#141414]/90 backdrop-blur-md'}`}>
<div className="px-4 md:px-16 py-4 flex items-center justify-between">
<div className="flex items-center gap-8">
{/* Logo - Renamed and removed Hamburger */}
<div className="flex items-center gap-4">
<Link href="/dracin" className="text-[#E50914] text-lg md:text-2xl font-bold uppercase whitespace-nowrap">
CINEPRIME
</Link>
</div>
{/* Desktop Links */}
<ul className="hidden md:flex gap-6 text-sm font-medium text-gray-400">
<li>
<Link href="/dracin" className={`hover:text-[#E50914] transition-colors ${isActive('/dracin') ? 'text-white font-bold' : ''}`}>
Home
</Link>
</li>
<li>
<Link href="/dracin/recommend" className={`hover:text-[#E50914] transition-colors ${isActive('/dracin/recommend') ? 'text-white font-bold' : ''}`}>
Recommend
</Link>
</li>
<li>
<Link href="/dracin/vip" className={`hover:text-[#E50914] transition-colors ${isActive('/dracin/vip') ? 'text-white font-bold' : ''}`}>
VIP
</Link>
</li>
<li>
<Link href="/dracin/categories" className={`hover:text-[#E50914] transition-colors ${isActive('/dracin/categories') ? 'text-white font-bold' : ''}`}>
Categories
</Link>
</li>
</ul>
</div>
{/* Desktop Search & Icons */}
<div className="hidden md:flex items-center gap-6">
<form onSubmit={handleSearch} className="relative w-64">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4 cursor-pointer" onClick={(e) => handleSearch(e as any)} />
<Input
type="text"
placeholder="Search Dracin..."
className="pr-9 h-9 bg-black/50 border-gray-700 text-white placeholder:text-gray-500 focus-visible:ring-[#E50914]"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</form>
{/* Language Selector */}
<div className="relative lang-selector text-gray-300 hover:text-white flex items-center gap-1 z-50">
<button
onClick={(e) => {
e.stopPropagation();
setIsLangMenuOpen(!isLangMenuOpen);
}}
className="text-sm font-medium focus:outline-none flex items-center gap-1 min-w-[90px] justify-end"
>
{selectedLang}
</button>
{isLangMenuOpen && (
<div className="absolute top-full right-0 mt-2 w-32 bg-[#141414] border border-gray-800 rounded-md shadow-lg py-2">
<button
onClick={() => handleLangSelect('in', 'Bahasa (ID)')}
className="block w-full text-left px-4 py-2 hover:bg-gray-800 text-sm text-gray-300 hover:text-white"
>
Bahasa (ID)
</button>
<button
onClick={() => handleLangSelect('en', 'English (EN)')}
className="block w-full text-left px-4 py-2 hover:bg-gray-800 text-sm text-gray-300 hover:text-white"
>
English (EN)
</button>
</div>
)}
</div>
<div className="flex items-center gap-4 text-white">
<Bell className="w-5 h-5 cursor-pointer hover:text-gray-300" />
</div>
</div>
{/* Mobile Icons - Minimal like Navbar */}
<div className="flex md:hidden items-center gap-4 text-white">
{/* Search Icon for Mobile */}
<Link href="/dracin/search">
<Search className="w-5 h-5" />
</Link>
<Bell className="w-5 h-5" />
</div>
</div>
</nav>
);
}

62
components/Hero.tsx Normal file
View File

@@ -0,0 +1,62 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { Play, Info } from 'lucide-react';
import { Subject, BannerItem } from '@/lib/api';
interface HeroProps {
item: BannerItem | Subject;
}
export default function Hero({ item }: HeroProps) {
// Prefer 'image' (often wider/better for banner) over 'cover', or fallback.
// Note: API types might vary. Safe access.
const imageUrl = (item as any).image?.url || (item as any).cover?.url || (item as any).subject?.cover?.url;
const title = item.title || (item as any).subject?.title;
const description = (item as any).description || (item as any).subject?.description || "No description available.";
let id = item.subjectId || (item as any).subject?.subjectId;
if (typeof id !== 'string') {
console.warn("Hero item ID is not a string:", id);
id = "";
}
if (!imageUrl) return null;
return (
<div className="relative h-[56.25vw] min-h-[60vh] w-full">
<Image
src={imageUrl}
alt={title}
fill
className="object-cover brightness-75"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
<div className="absolute top-[30%] md:top-[40%] ml-4 md:ml-16 w-[90%] md:w-[40%]">
<h1 className="text-4xl md:text-6xl font-bold text-white drop-shadow-md mb-4">
{title}
</h1>
<p className="text-white text-sm md:text-lg drop-shadow-md mb-6 line-clamp-3">
{description}
</p>
<div className="flex flex-row gap-3">
<Link
href={`/movie/${id}`}
className="bg-white text-black py-2 px-4 md:px-6 rounded flex items-center gap-2 hover:bg-opacity-80 transition font-semibold"
>
<Play className="w-5 h-5 fill-black" /> Play
</Link>
<Link
href={`/movie/${id}`}
className="bg-gray-500/70 text-white py-2 px-4 md:px-6 rounded flex items-center gap-2 hover:bg-opacity-50 transition font-semibold"
>
<Info className="w-5 h-5" /> More Info
</Link>
</div>
</div>
</div>
);
}

130
components/HeroSlider.tsx Normal file
View File

@@ -0,0 +1,130 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Play, Info, ChevronLeft, ChevronRight } from 'lucide-react';
import { Subject, BannerItem } from '@/lib/api';
interface HeroSliderProps {
items: (BannerItem | Subject)[];
}
export default function HeroSlider({ items }: HeroSliderProps) {
const [currentIndex, setCurrentIndex] = useState(0);
// Auto-advance
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((current) => (current + 1) % items.length);
}, 8000); // 8 seconds per slide
return () => clearInterval(timer);
}, [items.length]);
const nextSlide = () => {
setCurrentIndex((current) => (current + 1) % items.length);
};
const prevSlide = () => {
setCurrentIndex((current) => (current - 1 + items.length) % items.length);
};
if (!items || items.length === 0) return null;
const currentItem = items[currentIndex];
// Data extraction helper
const getData = (item: any) => {
const imageUrl = item.image?.url || item.cover?.url || item.subject?.cover?.url;
const title = item.title || item.subject?.title;
const description = item.description || item.subject?.description || "No description available.";
let id = item.subjectId || item.subject?.subjectId;
if (typeof id !== 'string') {
id = "";
}
return { imageUrl, title, description, id };
};
return (
<div className="relative h-[56.25vw] min-h-[60vh] w-full group overflow-hidden">
{/* Background Images - Transition Group or simple absolute positioning */}
{items.map((item, index) => {
const { imageUrl, title } = getData(item);
if (!imageUrl) return null;
return (
<div
key={index}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${index === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'}`}
>
<Image
src={imageUrl}
alt={title || "Banner"}
fill
className="object-cover brightness-75"
priority={index === 0} // High priority for first image
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
</div>
);
})}
{/* Content Overlay */}
<div className="absolute top-[30%] md:top-[40%] ml-4 md:ml-16 w-[90%] md:w-[40%] z-20">
{/* We render content for current index */}
{(() => {
const { title, description, id } = getData(currentItem);
return (
<div className="animate-fade-in">
<h1 className="text-4xl md:text-6xl font-bold text-white drop-shadow-md mb-4 transition-all duration-500">
{title}
</h1>
<p className="text-white text-sm md:text-lg drop-shadow-md mb-6 line-clamp-3 transition-all duration-500 delay-100">
{description}
</p>
<div className="flex flex-row gap-3 transition-all duration-500 delay-200">
<Link
href={`/movie/${id}`}
className="bg-white text-black py-2 px-4 md:px-6 rounded flex items-center gap-2 hover:bg-opacity-80 transition font-semibold"
>
<Play className="w-5 h-5 fill-black" /> Play
</Link>
<Link
href={`/movie/${id}`}
className="bg-gray-500/70 text-white py-2 px-4 md:px-6 rounded flex items-center gap-2 hover:bg-opacity-50 transition font-semibold"
>
<Info className="w-5 h-5" /> More Info
</Link>
</div>
</div>
);
})()}
</div>
{/* Navigation Buttons (visible on hover) */}
<button
onClick={prevSlide}
className="absolute left-4 top-1/2 -translate-y-1/2 z-30 p-2 bg-black/30 rounded-full hover:bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={nextSlide}
className="absolute right-4 top-1/2 -translate-y-1/2 z-30 p-2 bg-black/30 rounded-full hover:bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* Indicators */}
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-30 flex gap-3">
{items.map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`w-3 h-3 rounded-full transition-all border border-white/50 ${index === currentIndex ? 'bg-white scale-125' : 'bg-transparent hover:bg-white/50'}`}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
export default function LoadingSplash() {
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#141414]">
<div className="flex flex-col items-center animate-pulse">
<h1 className="text-[#E50914] text-4xl md:text-6xl font-bold tracking-tighter uppercase mb-4 drop-shadow-lg">
CINEPRIME
</h1>
<div className="w-8 h-8 md:w-12 md:h-12 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin"></div>
</div>
<p className="mt-4 text-gray-500 text-sm animate-pulse">Loading amazing content...</p>
</div>
);
}

33
components/MovieCard.tsx Normal file
View File

@@ -0,0 +1,33 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { Subject } from '@/lib/api';
interface MovieCardProps {
movie: Subject;
}
export default function MovieCard({ movie }: MovieCardProps) {
const imageUrl = movie.cover?.url || movie.image?.url;
if (!imageUrl) return null;
const linkHref = movie.isDracin ? `/dracin/${movie.subjectId}` : `/movie/${movie.subjectId}`;
return (
<Link href={linkHref} className="group relative min-w-[200px] h-[300px] cursor-pointer transition duration-200 ease-in-out md:hover:scale-110 md:hover:z-50">
<div className="relative w-full h-full rounded overflow-hidden">
<Image
src={imageUrl}
alt={movie.title}
fill
className="object-cover rounded"
sizes="(max-width: 768px) 150px, 200px"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition duration-300 flex items-end p-2">
<p className="text-white text-sm font-bold text-center w-full">{movie.title}</p>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,332 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Play } from 'lucide-react';
import { MovieDetail, getSources, generateStreamLink, getRecommendations, Subject } from '@/lib/api';
import VideoPlayer from './VideoPlayer';
import MovieRow from './MovieRow';
import BookmarkButton from './BookmarkButton';
interface MovieDetailViewProps {
detail: MovieDetail;
}
export default function MovieDetailView({ detail }: MovieDetailViewProps) {
const { subject, resource } = detail;
const isSeries = subject.subjectType === 2;
// Series State
const [selectedSeason, setSelectedSeason] = useState(isSeries && resource?.seasons?.[0]?.se || 0);
const [selectedEpisode, setSelectedEpisode] = useState(1);
// Player State
const [isPlaying, setIsPlaying] = useState(false);
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [captions, setCaptions] = useState<any[]>([]); // Use appropriate type if imported or allow implicit
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Recommendations State
const [recommendations, setRecommendations] = useState<Subject[]>([]);
const currentSeason = resource?.seasons?.find(s => s.se === selectedSeason);
const episodeCount = currentSeason?.maxEp || 0;
const [sources, setSources] = useState<any[]>([]);
const [currentQuality, setCurrentQuality] = useState(0);
const [historyResume, setHistoryResume] = useState<{ episode?: number; season?: number; position?: number } | null>(null);
const router = useRouter();
const supabase = createClient();
useEffect(() => {
const fetchRecs = async () => {
if (subject?.subjectId) {
try {
const recs = await getRecommendations(subject.subjectId);
setRecommendations(recs);
} catch (e) {
console.error("Failed to fetch recommendations", e);
}
}
};
fetchRecs();
}, [subject?.subjectId]);
const searchParams = useSearchParams();
useEffect(() => {
const fetchHistory = async () => {
if (!subject?.subjectId) return;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('history')
.select('*')
.eq('user_id', user.id)
.eq('subject_id', subject.subjectId)
.single();
if (data) {
setHistoryResume({
episode: data.episode || 1,
season: data.season || 0,
position: data.last_position
});
if (isSeries) {
if (data.season) setSelectedSeason(data.season);
if (data.episode) setSelectedEpisode(data.episode);
}
// Autoplay after history is set
const autoplay = searchParams?.get('autoplay');
if (autoplay === 'true') {
// Small delay to ensure state is committed
setTimeout(() => {
handlePlay(data.season || 0, data.episode || 1);
}, 500);
}
}
};
fetchHistory();
}, [subject?.subjectId, isSeries, searchParams]);
const handlePlay = async (sOverride?: number, eOverride?: number) => {
// Check Auth
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
return;
}
if (isPlaying) return;
setLoading(true);
setError('');
try {
// Fetch Source
const s = sOverride !== undefined ? sOverride : (isSeries ? selectedSeason : 0);
const e = eOverride !== undefined ? eOverride : (isSeries ? selectedEpisode : 0);
// ... rest of logic
const data = await getSources(subject.subjectId, subject.detailPath, s, e);
const { sources: fetchedSources, captions: fetchedCaptions } = data;
if (!fetchedSources || fetchedSources.length === 0) {
throw new Error("Source not found");
}
// Pick best quality (max resolution)
const sorted = fetchedSources.sort((a, b) => b.resolution - a.resolution);
const bestSource = sorted[0];
if (!bestSource || !bestSource.url) {
throw new Error("Playable URL not found");
}
// Generate Link
const link = await generateStreamLink(bestSource.url);
if (!link) throw new Error("Stream link generation failed");
setSources(sorted);
setStreamUrl(link);
setCaptions(fetchedCaptions);
setCurrentQuality(bestSource.resolution);
setIsPlaying(true);
} catch (err) {
console.error(err);
setError("Failed to load video. Please try again.");
} finally {
setLoading(false);
}
};
const changeQuality = async (source: any) => {
try {
const link = await generateStreamLink(source.url);
if (link) {
setStreamUrl(link);
setCurrentQuality(source.resolution);
}
} catch (e) {
console.error("Failed to change quality", e);
}
};
return (
<div className="relative min-h-screen bg-[#141414] text-white font-sans">
{/* Background Image / Backdrop */}
<div className="absolute top-0 left-0 w-full h-[70vh] opacity-50 z-0">
<Image
src={subject.cover?.url || subject.image?.url || ''}
alt={subject.title}
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
</div>
{/* Content */}
<div className="relative z-10 pt-[20vh] px-4 md:px-16 flex flex-col md:flex-row gap-8">
{/* Poster */}
<div className="flex-shrink-0 w-[200px] md:w-[300px] h-[300px] md:h-[450px] relative rounded shadow-2xl">
<Image
src={subject.cover?.url || ''}
alt={subject.title}
fill
className="object-cover rounded"
/>
</div>
{/* Details */}
<div className="flex-1 mt-4 md:mt-0">
<h1 className="text-4xl md:text-5xl font-bold mb-4">{subject.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-6 flex-wrap">
<span>{subject.releaseDate}</span>
<span className="border border-gray-600 px-1 text-xs rounded">HD</span>
<span>{subject.genre}</span>
<span>{subject.duration ? `${Math.round(subject.duration / 60)}m` : ''}</span>
{subject.countryName && <span> {subject.countryName}</span>}
{subject.imdbRatingValue && (
<span className="flex items-center gap-1 text-yellow-500">
{subject.imdbRatingValue}
</span>
)}
</div>
{/* Controls */}
<div className="space-y-6 mb-8">
{isSeries && (
<div className="space-y-4 bg-[#1f1f1f] p-4 rounded-lg max-w-xl">
<div className="flex items-center gap-4">
<span className="text-gray-400">Season</span>
<select
value={selectedSeason}
onChange={(e) => {
setSelectedSeason(Number(e.target.value));
setSelectedEpisode(1);
}}
className="bg-black border border-gray-700 rounded px-2 py-1"
>
{resource?.seasons?.map(s => (
<option key={s.se} value={s.se}>Season {s.se}</option>
))}
</select>
</div>
<div>
<span className="text-gray-400 block mb-2">Episode: {selectedEpisode}</span>
<div className="grid grid-cols-5 md:grid-cols-8 gap-2 max-h-40 overflow-y-auto no-scrollbar">
{Array.from({ length: episodeCount }, (_, i) => episodeCount - i).map(ep => (
<button
key={ep}
onClick={() => setSelectedEpisode(ep)}
className={`py-2 px-1 rounded text-center text-sm font-medium transition ${selectedEpisode === ep
? 'bg-primary text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{ep}
</button>
))}
</div>
</div>
</div>
)}
<div className="flex items-center gap-4">
<button
onClick={() => handlePlay()}
disabled={loading}
className={`
flex items-center gap-2 bg-white text-black px-8 py-3 rounded font-bold text-xl hover:bg-gray-200 transition
${loading ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{loading ? 'Loading...' : (
<>
<Play className="w-6 h-6 fill-black" />
{isSeries ? `Play S${selectedSeason} E${selectedEpisode}` : 'Play Movie'}
</>
)}
</button>
<BookmarkButton
id={subject.subjectId}
type="movie"
title={subject.title}
poster={subject.cover?.url || subject.image?.url || ''}
className="h-[52px] w-[52px] bg-[#1f1f1f] border-gray-700 hover:bg-[#333]"
/>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500 text-red-100 p-3 rounded mt-4">
<p className="font-semibold">{error}</p>
</div>
)}
</div>
<p className="text-lg text-gray-200 mb-8 max-w-2xl leading-relaxed">
{subject.description}
</p>
{/* Cast and More Info */}
<div className="mb-8 max-w-3xl">
{detail.stars && detail.stars.length > 0 && (
<div className="mb-6">
<h3 className="text-gray-400 font-bold mb-2">Cast</h3>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-gray-300">
{detail.stars.map((star: any, index: number) => (
<div key={index} className="flex items-center gap-2 bg-[#1f1f1f] px-3 py-1 rounded-full">
{star.avatarUrl || star.avatar || star.image ? (
<Image
src={star.avatarUrl || star.avatar || star.image}
alt={star.name}
width={24}
height={24}
className="rounded-full object-cover"
/>
) : null}
<span>{star.name}</span>
{star.character && <span className="text-gray-500 text-xs">as {star.character}</span>}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Recommendations */}
{recommendations.length > 0 && (
<div className="relative z-10 w-full pb-16">
<MovieRow title="More Like This" movies={recommendations} />
</div>
)}
{isPlaying && streamUrl && (
<VideoPlayer
url={streamUrl}
captions={captions}
sources={sources}
currentResolution={currentQuality}
onQualityChange={changeQuality}
onClose={() => setIsPlaying(false)}
// History Data
subjectId={subject.subjectId}
type={isSeries ? 'series' : 'movie'}
title={subject.title}
poster={subject.cover?.url || subject.image?.url || ''}
season={isSeries ? selectedSeason : undefined}
episode={isSeries ? selectedEpisode : undefined}
startTime={(historyResume?.season === selectedSeason && historyResume?.episode === selectedEpisode) || (!isSeries && historyResume) ? historyResume?.position : 0}
/>
)}
</div>
);
}

56
components/MovieRow.tsx Normal file
View File

@@ -0,0 +1,56 @@
'use client';
import { Subject } from '@/lib/api';
import MovieCard from './MovieCard';
import { useRef } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface MovieRowProps {
title: string;
movies: Subject[];
headerContent?: React.ReactNode;
}
export default function MovieRow({ title, movies, headerContent }: MovieRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const slide = (offset: number) => {
if (rowRef.current) {
rowRef.current.scrollLeft += offset;
}
};
if (!movies || movies.length === 0) return null;
return (
<div className="px-4 md:px-12 my-8 space-y-4 group">
<div className="mb-2">
<h2 className="text-white text-xl md:text-2xl font-semibold hover:text-gray-300 cursor-pointer transition inline-block">
{title}
</h2>
{headerContent}
</div>
<div className="relative">
<button
className="absolute left-0 top-0 bottom-0 z-40 bg-black/50 hover:bg-black/70 w-12 hidden md:group-hover:flex items-center justify-center transition opacity-0 group-hover:opacity-100"
onClick={() => slide(-500)}
>
<ChevronLeft className="text-white w-8 h-8" />
</button>
<div ref={rowRef} className="flex items-center gap-4 overflow-x-scroll no-scrollbar scroll-smooth p-2">
{movies.map((movie) => (
<MovieCard key={movie.subjectId} movie={movie} />
))}
</div>
<button
className="absolute right-0 top-0 bottom-0 z-40 bg-black/50 hover:bg-black/70 w-12 hidden md:group-hover:flex items-center justify-center transition opacity-0 group-hover:opacity-100"
onClick={() => slide(500)}
>
<ChevronRight className="text-white w-8 h-8" />
</button>
</div>
</div>
);
}

61
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,61 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Search, Bell } from 'lucide-react';
export default function Navbar() {
const pathname = usePathname();
const router = useRouter();
const [isScrolled, setIsScrolled] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 0) {
setIsScrolled(true);
} else {
setIsScrolled(false);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?keyword=${encodeURIComponent(searchQuery)}`);
}
};
if (pathname?.startsWith('/admin') || pathname?.startsWith('/login') || pathname?.startsWith('/dracin/watch')) {
return null;
}
return (
<nav className={`fixed w-full z-[9999] transition-colors duration-300 ${isScrolled ? 'bg-[#141414]' : 'bg-[#141414]/80 backdrop-blur-sm'}`}>
<div className="px-4 md:px-16 py-3 flex items-center justify-between gap-4 transition-all duration-500">
{/* Logo */}
<Link href="/" className="text-[#E50914] text-xl md:text-2xl font-bold shrink-0">
CINEPRIME
</Link>
{/* Search Box */}
{/* Search Box */}
<div className="flex items-center gap-4">
<Link href="/search" className="p-2 text-white hover:text-gray-300 transition">
<Search className="w-6 h-6" />
</Link>
{/* Notification Icon */}
<div className="flex items-center shrink-0">
<Bell className="w-6 h-6 text-white cursor-pointer hover:text-gray-300 transition" />
</div>
</div>
</div>
</nav>
);
}

206
components/VideoPlayer.tsx Normal file
View File

@@ -0,0 +1,206 @@
import { useState, useEffect, useRef } from 'react';
import { X, Settings } from 'lucide-react';
import { Caption, SourceData } from '@/lib/api';
import { createClient } from '@/lib/supabase/client';
import { savePlaybackProgress } from '@/lib/history-service';
interface VideoPlayerProps {
url: string;
captions?: Caption[];
sources?: SourceData[];
onQualityChange?: (source: SourceData) => void;
currentResolution?: number;
onClose: () => void;
// History Props
subjectId?: string;
type?: 'movie' | 'series' | 'dracin';
title?: string;
poster?: string;
season?: number;
episode?: number;
startTime?: number;
}
export default function VideoPlayer({
url,
captions = [],
sources = [],
onQualityChange,
currentResolution,
onClose,
subjectId,
type,
title,
poster,
season,
episode,
startTime = 0
}: VideoPlayerProps) {
const [processedCaptions, setProcessedCaptions] = useState<{ id: string; url: string; label: string; lang: string }[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const [showQualityMenu, setShowQualityMenu] = useState(false);
const [savedTime, setSavedTime] = useState(startTime);
const supabase = createClient();
// Initial load restoration
useEffect(() => {
if (videoRef.current && savedTime > 0) {
videoRef.current.currentTime = savedTime;
}
}, [url]);
// History Tracking
useEffect(() => {
if (!subjectId || !type || !title) return;
const interval = setInterval(() => {
saveProgress();
}, 15000); // Save every 15 seconds
return () => {
clearInterval(interval);
saveProgress(); // Final save on unmount
};
}, [subjectId, type, title, season, episode]);
const saveProgress = async () => {
if (!videoRef.current || !subjectId || !type || !title) return;
const currentTime = videoRef.current.currentTime;
const duration = videoRef.current.duration;
if (currentTime === 0 && duration === 0) return;
savePlaybackProgress({
subjectId,
type,
title,
poster: poster || '',
season,
episode,
lastPosition: currentTime,
duration
});
};
useEffect(() => {
const processCaptions = async () => {
if (!captions.length) return;
const processed = await Promise.all(
captions.map(async (cap) => {
try {
const response = await fetch(cap.url);
if (!response.ok) return null;
const srtText = await response.text();
// Simple SRT to VTT conversion:
// 1. Replace commas in timestamps with dots
// 2. Add WEBVTT header
const vttText = "WEBVTT\n\n" + srtText.replace(/(^\d+)\s+$/gm, '$1').replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2');
const blob = new Blob([vttText], { type: 'text/vtt' });
const blobUrl = URL.createObjectURL(blob);
return {
id: cap.id,
url: blobUrl,
label: cap.lanName,
lang: cap.lan
};
} catch (error) {
console.error("Failed to process caption:", cap.lanName, error);
return null;
}
})
);
const validCaptions = processed.filter((c): c is { id: string; url: string; label: string; lang: string } => c !== null);
setProcessedCaptions(validCaptions);
};
processCaptions();
// Cleanup blob URLs
return () => {
processedCaptions.forEach(c => URL.revokeObjectURL(c.url));
};
}, [captions]);
const handleQualityChange = (source: SourceData) => {
if (videoRef.current) {
setSavedTime(videoRef.current.currentTime);
}
setShowQualityMenu(false);
if (onQualityChange) {
onQualityChange(source);
}
};
return (
<div className="fixed inset-0 z-[10000] bg-black flex items-center justify-center">
{/* Quality Selector */}
{sources.length > 0 && onQualityChange && (
<div className="absolute top-4 right-16 z-50">
<button
onClick={() => setShowQualityMenu(!showQualityMenu)}
className="text-white hover:text-primary p-2 bg-black/50 rounded-full flex items-center gap-2"
>
<Settings className="w-6 h-6" />
<span className="font-bold text-sm hidden md:block">{currentResolution ? `${currentResolution}p` : 'Quality'}</span>
</button>
{showQualityMenu && (
<div className="absolute top-full right-0 mt-2 bg-gray-900 border border-gray-700 rounded shadow-xl py-2 w-32">
{sources.map((s) => (
<button
key={s.id}
onClick={() => handleQualityChange(s)}
className={`block w-full text-left px-4 py-2 hover:bg-gray-800 transition text-sm ${currentResolution === s.resolution ? 'text-primary font-bold' : 'text-gray-300'
}`}
>
{s.resolution}p
</button>
))}
</div>
)}
</div>
)}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white hover:text-red-500 z-50 p-2 bg-black/50 rounded-full"
>
<X className="w-8 h-8" />
</button>
<video
ref={videoRef}
src={url}
controls
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
autoPlay
className="w-full h-full object-contain"
crossOrigin="anonymous"
onLoadedData={() => {
// Restore time if needed (usually handled by useEffect on url change, but redundant check safe)
if (videoRef.current && savedTime > 0 && Math.abs(videoRef.current.currentTime - savedTime) > 1) {
videoRef.current.currentTime = savedTime;
}
}}
>
{processedCaptions.map((cap) => (
<track
key={cap.id}
kind="subtitles"
src={cap.url}
srcLang={cap.lang}
label={cap.label}
default={cap.lang === 'en' || cap.lang.startsWith('en')}
/>
))}
</video>
</div>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export default function AdminHeader() {
return (
<header className="hidden md:flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-[#0a0a0a] px-6">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-400">Admin User</span>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>AD</AvatarFallback>
</Avatar>
</div>
</header>
)
}

View File

@@ -0,0 +1,86 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Users, Settings, LogOut, Menu, Shield, Film } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
const sidebarItems = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Movies', href: '/admin/movies', icon: Film },
{ name: 'Users', href: '/admin/users', icon: Users },
{ name: 'Roles', href: '/admin/roles', icon: Shield },
{ name: 'Settings', href: '/admin/settings', icon: Settings },
];
export default function AdminSidebar() {
const pathname = usePathname();
const router = useRouter();
const supabase = createClient();
const handleLogout = async () => {
await supabase.auth.signOut();
router.push('/login');
};
const SidebarContent = () => (
<div className="flex flex-col h-full">
<div className="p-6">
<h1 className="text-2xl font-bold text-[#E50914]">CinePrime Admin</h1>
</div>
<nav className="flex-1 px-4 space-y-2">
{sidebarItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive ? 'bg-primary/10 text-primary' : 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-800">
<Button
variant="ghost"
className="w-full flex justify-start gap-3 text-red-500 hover:text-red-400 hover:bg-red-500/10"
onClick={handleLogout}
>
<LogOut className="w-5 h-5" />
Logout
</Button>
</div>
</div>
);
return (
<>
{/* Desktop Sidebar */}
<aside className="hidden md:flex w-64 flex-col fixed inset-y-0 left-0 bg-[#0a0a0a] border-r border-gray-800 z-50">
<SidebarContent />
</aside>
{/* Mobile Sidebar */}
<div className="md:hidden flex items-center p-4 bg-[#0a0a0a] border-b border-gray-800 sticky top-0 z-50">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="w-6 h-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 bg-[#0a0a0a] border-r border-gray-800 text-white w-64">
<SidebarContent />
</SheetContent>
</Sheet>
<span className="ml-4 font-bold text-lg">Admin Panel</span>
</div>
</>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Season } from '@/lib/api';
import { useMemo, useState, useEffect } from 'react';
interface EpisodeSelectorProps {
seasons: Season[];
currentSeason: number;
currentEpisode: number;
}
export function EpisodeSelector({ seasons, currentSeason, currentEpisode }: EpisodeSelectorProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Default to first season if currentSeason is 0
const [selectedSeason, setSelectedSeason] = useState<string>(
currentSeason > 0 ? currentSeason.toString() : (seasons[0]?.se.toString() || '1')
);
const activeSeason = useMemo(() =>
seasons.find(s => s.se.toString() === selectedSeason),
[seasons, selectedSeason]
);
const episodes = useMemo(() => {
if (!activeSeason) return [];
return Array.from({ length: activeSeason.maxEp }, (_, i) => i + 1);
}, [activeSeason]);
const handleSeasonChange = (val: string) => {
setSelectedSeason(val);
// When season changes, we don't necessarily update URL until episode is picked?
// Or we can just reset episode logic.
// Let's just update local state, user must pick episode.
};
const handleEpisodeChange = (val: string) => {
const params = new URLSearchParams(searchParams);
params.set('season', selectedSeason);
params.set('episode', val);
router.push(`${pathname}?${params.toString()}`);
};
// Update local state if prop changes (e.g. navigation)
useEffect(() => {
if (currentSeason > 0) {
setSelectedSeason(currentSeason.toString());
}
}, [currentSeason]);
return (
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Season</span>
<Select value={selectedSeason} onValueChange={handleSeasonChange}>
<SelectTrigger className="w-[140px] bg-gray-950 border-gray-700">
<SelectValue placeholder="Select Season" />
</SelectTrigger>
<SelectContent>
{seasons.map((season) => (
<SelectItem key={season.se} value={season.se.toString()}>
Season {season.se}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Episode</span>
<Select
value={currentEpisode > 0 && currentSeason.toString() === selectedSeason ? currentEpisode.toString() : ''}
onValueChange={handleEpisodeChange}
disabled={!activeSeason}
>
<SelectTrigger className="w-[140px] bg-gray-950 border-gray-700">
<SelectValue placeholder="Select Episode" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{episodes.map((ep) => (
<SelectItem key={ep} value={ep.toString()}>
Episode {ep}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
hasMore: boolean;
}
export function Pagination({ currentPage, hasMore }: PaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `?${params.toString()}`;
};
return (
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage - 1))}
disabled={currentPage <= 1}
className="bg-transparent border-gray-800 text-white hover:bg-gray-800 hover:text-white"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="text-sm text-gray-400">
Page {currentPage}
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage + 1))}
disabled={!hasMore}
className="bg-transparent border-gray-800 text-white hover:bg-gray-800 hover:text-white"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

57
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,57 @@
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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
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 }

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

159
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

31
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

29
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

120
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}