first commit

This commit is contained in:
Lars Behrends
2026-04-09 10:29:11 +02:00
commit dda118a2f7
36 changed files with 14470 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem';
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import React, { useState, useMemo, useEffect } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from '@/lib/utils';
import { AnimatePresence } from 'motion/react';
interface BrowseViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
onAddMedia: (media: Media) => void;
activeCategory: MediaCategory;
}
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [sortBy, setSortBy] = useState<string>('default');
// Add Media Dialog State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [newMedia, setNewMedia] = useState({
title: '',
year: '',
poster: '',
category: activeCategory as MediaCategory,
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
});
// Update category and default aspect ratio when activeCategory changes
useEffect(() => {
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
if (activeCategory === 'Music') defaultAspect = '1/1';
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
setNewMedia(prev => ({
...prev,
category: activeCategory,
aspectRatio: defaultAspect
}));
}, [activeCategory]);
const handleAddSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newMedia.title || !newMedia.poster) return;
onAddMedia({
id: Math.random().toString(36).substr(2, 9),
title: newMedia.title,
year: newMedia.year || new Date().getFullYear().toString(),
poster: newMedia.poster,
category: newMedia.category,
aspectRatio: newMedia.aspectRatio,
status: 'planned'
});
setNewMedia({
title: '',
year: '',
poster: '',
category: activeCategory,
aspectRatio: '2/3'
});
setIsAddDialogOpen(false);
};
// Filter states
const [selectedType, setSelectedType] = useState<string | null>(null);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
// Extract unique values for filters
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const filteredMedia = useMemo(() => {
return mediaList.filter(media => {
if (selectedType && media.type !== selectedType) return false;
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
return true;
});
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
// Reset to first page when mediaList or filters change
useEffect(() => {
setCurrentPage(1);
}, [filteredMedia, sortBy]);
const sortedMedia = useMemo(() => {
const list = [...filteredMedia];
if (sortBy === 'title-asc') {
return list.sort((a, b) => a.title.localeCompare(b.title));
}
if (sortBy === 'title-desc') {
return list.sort((a, b) => b.title.localeCompare(a.title));
}
return list;
}, [filteredMedia, sortBy]);
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
const paginatedMedia = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
}, [sortedMedia, currentPage, itemsPerPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Filter size={16} />
{selectedType || 'Media Type'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
{allTypes.map(type => (
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Genre Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Star size={16} />
{selectedGenre || 'Genres'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
{allGenres.sort().map(genre => (
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Studio Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
Studios
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
{allStudios.sort().map(studio => (
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{(selectedType || selectedGenre || selectedStudio) && (
<Button
variant="link"
size="sm"
className="text-zinc-400 font-bold"
onClick={() => {
setSelectedType(null);
setSelectedGenre(null);
setSelectedStudio(null);
}}
>
Clear Filters
</Button>
)}
</div>
<div className="flex items-center gap-4">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
<Plus size={20} />
ADD NEW
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<form onSubmit={handleAddSubmit}>
<DialogHeader>
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle>
<DialogDescription className="text-zinc-500 font-medium">
Manually add a new item to your {activeCategory} library.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-6">
<div className="grid gap-2">
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input
id="title"
value={newMedia.title}
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g. Mob Psycho 100"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
<Input
id="year"
value={newMedia.year}
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
placeholder="2024"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
<select
id="category"
value={newMedia.category}
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<select
id="aspectRatio"
value={newMedia.aspectRatio}
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
<option value="1/1">1:1 (Square - Music)</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
<Input
id="poster"
value={newMedia.poster}
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
placeholder="https://example.com/poster.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
SAVE TO LIBRARY
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2">
<ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('title-asc')}>Title (A-Z)</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('title-desc')}>Title (Z-A)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center bg-zinc-100 rounded-md p-1">
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'grid' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
)}
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 transition-all",
viewMode === 'list' ? "bg-white shadow-sm text-[#6d28d9]" : "text-zinc-400"
)}
onClick={() => setViewMode('list')}
>
<List size={16} />
</Button>
</div>
</div>
</div>
{/* Content */}
{mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
<Filter size={32} />
</div>
<p className="text-lg font-bold">No results found</p>
<p className="text-sm">Try adjusting your search or filters</p>
</div>
) : (
<div className={cn(
viewMode === 'grid'
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
: "flex flex-col gap-2"
)}>
<AnimatePresence mode="popLayout">
{paginatedMedia.map((media) => (
viewMode === 'grid' ? (
<MediaCard
key={media.id}
media={media}
onClick={onMediaClick}
/>
) : (
<MediaListItem
key={media.id}
media={media}
onClick={onMediaClick}
/>
)
))}
</AnimatePresence>
</div>
)}
{/* Pagination Controls */}
{mediaList.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{[8, 12, 16, 24, 48].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-6">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
>
Next
<ChevronRight size={16} />
</Button>
</div>
</div>
)}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { Staff, Media } from '@/types';
import { motion } from 'motion/react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface CastDetailViewProps {
person: Staff;
onBack: () => void;
onMediaClick: (mediaId: string) => void;
relatedMedia: Media[];
}
export default function CastDetailView({ person, onBack, onMediaClick, relatedMedia }: CastDetailViewProps) {
return (
<div className="min-h-screen bg-white pb-20">
{/* Hero Section */}
<div className="relative h-[40vh] md:h-[50vh] overflow-hidden bg-zinc-900">
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
<div className="absolute inset-0 flex items-end px-6 pb-12">
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-white shadow-2xl shrink-0"
>
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</motion.div>
<div className="flex-1 text-center md:text-left pb-4">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<h1 className="text-4xl md:text-6xl font-black text-zinc-900 mb-4 drop-shadow-sm">
{person.name}
</h1>
<div className="flex flex-wrap justify-center md:justify-start gap-3">
{person.occupations?.map(occ => (
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-none font-bold px-4 py-1">
{occ}
</Badge>
))}
</div>
</motion.div>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
>
<ArrowLeft size={24} />
</Button>
</div>
{/* Content Section */}
<div className="max-w-[1200px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Sidebar Info */}
<div className="space-y-8">
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Personal Info</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Calendar size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Date</p>
<p className="font-bold text-zinc-700">{person.birthDate || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<MapPin size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Birth Place</p>
<p className="font-bold text-zinc-700">{person.birthPlace || 'Unknown'}</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Briefcase size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Known For</p>
<p className="font-bold text-zinc-700">{person.role}</p>
</div>
</div>
</div>
</div>
</div>
{/* Main Bio & Roles */}
<div className="lg:col-span-2 space-y-12">
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
Biography
</h2>
<p className="text-zinc-600 leading-relaxed text-lg">
{person.bio || `${person.name} is a talented ${person.role} known for their work in various media productions. They have brought numerous characters to life with their unique performances.`}
</p>
</section>
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<User className="text-[#6d28d9]" />
Characters
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{relatedMedia.map(media => {
const character = media.staff?.find(s => s.id === person.id);
if (!character) return null;
return (
<div
key={`${media.id}-char`}
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
>
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
<img
src={character.characterImage}
alt={character.characterName}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{character.characterName}</h4>
<button
onClick={() => onMediaClick(media.id)}
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
>
in {media.title}
</button>
</div>
</div>
);
})}
</div>
</section>
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{relatedMedia.map(media => (
<div
key={media.id}
onClick={() => onMediaClick(media.id)}
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h4>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
{media.year}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
{person.role}
</Badge>
</div>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { Staff } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';
interface CastViewProps {
staffList: Staff[];
onPersonClick: (person: Staff) => void;
}
export default function CastView({ staffList, onPersonClick }: CastViewProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'role'>('name');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const filteredStaff = useMemo(() => {
let list = staffList.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase())
);
return list.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
}, [staffList, searchQuery, sortBy]);
// Reset to first page when filters or sorting change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, sortBy, itemsPerPage]);
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage);
const paginatedStaff = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
}, [filteredStaff, currentPage, itemsPerPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">Cast & Staff</h1>
<p className="text-zinc-500 font-medium">Discover the people behind your favorite media</p>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<Input
placeholder="Search cast..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-zinc-100 border-none rounded-full h-11"
/>
</div>
<Button
variant="outline"
size="icon"
className="rounded-full h-11 w-11 border-zinc-200"
onClick={() => setSortBy(prev => prev === 'name' ? 'role' : 'name')}
>
<ArrowUpDown size={20} />
</Button>
</div>
</div>
{filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<User size={48} className="mb-4 opacity-20" />
<p className="text-lg font-bold">No cast members found</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence mode="popLayout">
{paginatedStaff.map((person) => (
<motion.div
key={`${person.id}-${person.mediaId}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="group bg-white rounded-2xl p-4 shadow-sm border border-zinc-100 hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
onClick={() => onPersonClick(person)}
>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-zinc-100 group-hover:border-[#6d28d9] transition-colors">
<img
src={person.photo}
alt={person.name}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{person.name}
</h3>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
{person.role}
</p>
</div>
</div>
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
<img
src={person.characterImage}
alt={person.characterName}
className="w-full h-full object-contain"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest leading-none mb-1">Character</p>
<p className="text-xs font-bold text-zinc-700 truncate">{person.characterName}</p>
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">in {person.mediaTitle}</p>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{/* Pagination Controls */}
{filteredStaff.length > 0 && (
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-zinc-100 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{[8, 12, 16, 24, 48].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-6">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-zinc-200"
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
>
Next
<ChevronRight size={16} />
</Button>
</div>
</div>
)}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { Media, Staff } from '@/types';
import {
Play,
Bookmark,
MoreHorizontal,
Star,
ChevronLeft,
ChevronRight,
Search,
ListFilter
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { motion } from 'motion/react';
interface DetailViewProps {
media: Media;
onBack: () => void;
onPersonClick: (person: Staff) => void;
}
export default function DetailView({ media, onBack, onPersonClick }: DetailViewProps) {
return (
<div className="min-h-screen bg-zinc-50">
{/* Banner */}
<div className="relative h-[400px] w-full overflow-hidden">
<img
src={media.banner || media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 via-zinc-50/40 to-transparent" />
<button
onClick={onBack}
className="absolute top-24 left-6 p-2 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors z-10"
>
<ChevronLeft size={24} />
</button>
</div>
{/* Content */}
<div className="max-w-[1400px] mx-auto px-6 -mt-32 relative z-10 pb-24">
<div className="flex flex-col md:flex-row gap-8">
{/* Left Column: Poster */}
<div className="w-full md:w-[300px] shrink-0">
<motion.div
layoutId={`media-${media.id}`}
className={`rounded-xl overflow-hidden shadow-2xl bg-zinc-800 ${
media.aspectRatio === '16/9' ? 'aspect-video' :
media.aspectRatio === '1/1' ? 'aspect-square' :
'aspect-[2/3]'
}`}
>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</motion.div>
</div>
{/* Right Column: Info */}
<div className="flex-1 pt-32 md:pt-40">
<div className="flex flex-wrap items-end justify-between gap-4 mb-6">
<div>
<h1 className="text-4xl font-black text-zinc-900 mb-2">
{media.title} <span className="text-zinc-400 font-medium">({media.year})</span>
</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button size="icon" className="rounded-full bg-[#6d28d9] hover:bg-[#5b21b6]">
<Play size={20} fill="currentColor" />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<Bookmark size={20} />
</Button>
<Button size="icon" variant="outline" className="rounded-full border-zinc-300">
<MoreHorizontal size={20} />
</Button>
</div>
<div className="flex items-center gap-1 text-zinc-600 font-bold">
<Star size={18} className="text-yellow-500" fill="currentColor" />
{media.rating} / 10
</div>
</div>
</div>
<div className="hidden lg:block text-right">
<h3 className="text-xs font-black text-[#6d28d9] uppercase tracking-wider mb-2">Genres</h3>
<div className="flex flex-col items-end gap-1">
{media.genres?.map(genre => (
<span key={genre} className="text-sm font-bold text-zinc-600 hover:text-[#6d28d9] cursor-pointer transition-colors">
{genre}
</span>
))}
</div>
</div>
</div>
<p className="text-zinc-600 leading-relaxed mb-8 max-w-3xl">
{media.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-8">
{media.tags?.map(tag => (
<Badge key={tag} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20 border-none px-3 py-1 font-bold text-[10px] uppercase tracking-wider">
{tag}
</Badge>
))}
</div>
<div className="space-y-4">
<p className="text-xs font-bold text-zinc-500">
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
{media.studios?.join(', ')}
</p>
<div className="flex items-center gap-4">
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">AniDb</Button>
</div>
</div>
</div>
</div>
{/* Staff Section - Only show if staff data exists */}
{media.staff && media.staff.length > 0 && (
<section className="mt-20">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-zinc-900">Cast & Crew</h2>
<div className="flex gap-2">
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronLeft size={18} />
</Button>
<Button variant="outline" size="icon" className="rounded-full border-zinc-200">
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{media.staff.map(person => (
<div
key={person.id}
className="flex items-center gap-4 bg-white p-3 rounded-xl shadow-sm border border-zinc-100 hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" referrerPolicy="no-referrer" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-zinc-500 truncate">{person.role}</p>
</div>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-zinc-50">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
</div>
</div>
))}
</div>
</section>
)}
{/* Episodes Section - Only show if episodes data exists */}
{media.episodes && media.episodes.length > 0 && (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-xl">
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-zinc-100 border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-zinc-400">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-zinc-400">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-6">
{media.episodes.map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-xl overflow-hidden shadow-sm relative">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-zinc-900 group-hover:text-[#6d28d9] transition-colors">
S1:E{episode.number} {episode.title}
</h3>
<span className="text-xs font-bold text-zinc-400">{episode.date} {episode.duration}</span>
</div>
<p className="text-sm text-zinc-500 leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
</div>
<Separator className="mt-6 bg-zinc-200" />
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { Search, User, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import React, { useState } from 'react';
import { MediaCategory } from '@/types';
import LibrarySettings from './LibrarySettings';
interface HeaderProps {
onBrowse: () => void;
onCast: () => void;
onSearch: (query: string) => void;
activeCategory: MediaCategory;
onCategoryChange: (category: MediaCategory) => void;
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
transparent?: boolean;
}
export default function Header({
onBrowse,
onCast,
onSearch,
activeCategory,
onCategoryChange,
enabledCategories,
onToggleCategory,
transparent
}: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
onSearch(query);
};
const toggleSearch = () => {
setIsSearchOpen(!isSearchOpen);
if (isSearchOpen) {
setSearchQuery('');
onSearch('');
}
};
return (
<header
className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
transparent ? "bg-transparent" : "bg-[#6d28d9]"
)}
>
<div className="flex items-center gap-8">
<div
className="text-2xl font-black text-white cursor-pointer flex items-center gap-1"
onClick={onBrowse}
>
<div className="w-6 h-6 bg-white rounded-full flex items-center justify-center">
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" />
</div>
kyoo
</div>
<nav className="hidden md:flex items-center gap-6">
{enabledCategories.map(cat => (
<button
key={cat}
onClick={() => onCategoryChange(cat)}
className={cn(
"text-sm font-bold transition-colors uppercase tracking-wider",
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
)}
>
{cat}
</button>
))}
<div className="w-px h-4 bg-white/20 mx-2" />
<button
onClick={onCast}
className="text-sm font-bold text-white/60 hover:text-white transition-colors uppercase tracking-wider"
>
CAST
</button>
</nav>
</div>
<div className="flex items-center gap-4">
<div className={cn(
"flex items-center transition-all duration-300 overflow-hidden",
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0"
)}>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={handleSearchChange}
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50"
autoFocus={isSearchOpen}
/>
</div>
<button
onClick={toggleSearch}
className="p-2 text-white/90 hover:text-white transition-colors"
>
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
</button>
<LibrarySettings
enabledCategories={enabledCategories}
onToggleCategory={onToggleCategory}
/>
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
<img
src="https://picsum.photos/seed/user/100/100"
alt="User"
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</button>
</div>
</header>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react';
import { MediaCategory } from '@/types';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Settings, Film, Music, Book, Tv, Gamepad2, ShieldAlert } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from '@/components/ui/button';
interface LibrarySettingsProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
}
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
Anime: <Tv size={18} />,
Movies: <Film size={18} />,
Music: <Music size={18} />,
Books: <Book size={18} />,
Consoles: <Gamepad2 size={18} />,
Games: <Gamepad2 size={18} />,
Adult: <ShieldAlert size={18} />,
};
export default function LibrarySettings({ enabledCategories, onToggleCategory }: LibrarySettingsProps) {
const categories: MediaCategory[] = ['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'];
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-white/90 hover:text-white transition-colors">
<Settings size={20} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-black text-zinc-900">Library Settings</DialogTitle>
<DialogDescription className="text-zinc-500 font-medium">
Toggle which media areas you want to see in your library.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-6">
{categories.map((category) => (
<div key={category} className="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
{CATEGORY_ICONS[category]}
</div>
<div>
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
{category}
</Label>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
{enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
</p>
</div>
</div>
<Switch
id={category}
checked={enabledCategories.includes(category)}
onCheckedChange={() => onToggleCategory(category)}
/>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Media } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
interface MediaCardProps {
key?: string;
media: Media;
onClick: (media: Media) => void;
}
export default function MediaCard({ media, onClick }: MediaCardProps) {
const statusColors = {
watching: 'bg-blue-500',
completed: 'bg-green-500',
planned: 'bg-gray-500',
dropped: 'bg-red-500',
reading: 'bg-amber-500',
listening: 'bg-purple-500',
playing: 'bg-indigo-500',
'on-hold': 'bg-orange-500',
};
const getAspectRatio = () => {
if (media.aspectRatio) return media.aspectRatio;
switch (media.category) {
case 'Music':
return '1/1';
case 'Games':
case 'Adult':
return '16/9';
case 'Anime':
case 'Movies':
case 'Books':
default:
return '2/3';
}
};
const aspectRatioClass = {
'2/3': 'aspect-[2/3]',
'16/9': 'aspect-[16/9]',
'1/1': 'aspect-[1/1]',
}[getAspectRatio()];
return (
<motion.div
layoutId={`media-${media.id}`}
className="group cursor-pointer"
onClick={() => onClick(media)}
whileHover={{ y: -4 }}
transition={{ duration: 0.2 }}
>
<div className={cn(
"relative rounded-lg overflow-hidden shadow-lg bg-zinc-800 transition-all duration-300",
aspectRatioClass
)}>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
referrerPolicy="no-referrer"
/>
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
statusColors[media.status]
)} />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<div className="mt-3 space-y-1">
<h3 className="text-sm font-bold text-zinc-900 line-clamp-1 group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h3>
<p className="text-xs font-medium text-zinc-500">
{media.year}
</p>
</div>
</motion.div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Media } from '@/types';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { Star, Play, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MediaListItemProps {
key?: string;
media: Media;
onClick: (media: Media) => void;
}
export default function MediaListItem({ media, onClick }: MediaListItemProps) {
const statusColors = {
watching: 'bg-blue-500',
completed: 'bg-green-500',
planned: 'bg-gray-500',
dropped: 'bg-red-500',
reading: 'bg-amber-500',
listening: 'bg-purple-500',
playing: 'bg-indigo-500',
'on-hold': 'bg-orange-500',
};
const getAspectRatio = () => {
if (media.aspectRatio) return media.aspectRatio;
switch (media.category) {
case 'Music': return '1/1';
case 'Games':
case 'Adult': return '16/9';
default: return '2/3';
}
};
const aspectRatioClass = {
'2/3': 'w-24 h-32',
'16/9': 'w-48 h-27', // 16:9 ratio for w-48 is approx h-27
'1/1': 'w-24 h-24',
}[getAspectRatio()];
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="group flex items-center gap-6 p-4 rounded-xl hover:bg-zinc-50 transition-colors cursor-pointer border border-transparent hover:border-zinc-200"
onClick={() => onClick(media)}
>
<div className={cn(
"relative rounded-lg overflow-hidden shrink-0 shadow-md bg-zinc-800 transition-all duration-300",
aspectRatioClass
)}>
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
{media.status && (
<div className={cn(
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
statusColors[media.status]
)} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h3>
<span className="text-sm font-bold text-zinc-400">({media.year})</span>
</div>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-1 text-xs font-bold text-zinc-500">
<Star size={14} className="text-yellow-500" fill="currentColor" />
{media.rating || 'N/A'}
</div>
<div className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
</div>
</div>
<p className="text-sm text-zinc-500 line-clamp-2 max-w-2xl">
{media.description || "No description available for this title."}
</p>
</div>
<div className="hidden md:flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Play size={18} fill="currentColor" />
</Button>
<Button size="icon" variant="ghost" className="rounded-full text-zinc-400 hover:text-[#6d28d9] hover:bg-[#6d28d9]/10">
<Bookmark size={18} />
</Button>
</div>
</motion.div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+266
View File
@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }