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

235
src/App.tsx Normal file
View File

@@ -0,0 +1,235 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
import Header from './components/Header';
import BrowseView from './components/BrowseView';
import DetailView from './components/DetailView';
import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
export default function App() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load adult media on component mount
useEffect(() => {
const loadAdultMedia = async () => {
try {
const media = await fetchMediaFromLocalJson();
// Add category to adult media
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
setAdultMedia(categorizedMedia);
} catch (error) {
console.error('Failed to load adult media:', error);
}
};
loadAdultMedia();
}, []);
const toggleCategory = (category: MediaCategory) => {
setEnabledCategories(prev => {
const isEnabling = !prev.includes(category);
const newList = isEnabling
? [...prev, category]
: prev.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) {
const nextCategory = newList.find(c => c !== category) || 'Anime';
setActiveCategory(nextCategory as MediaCategory);
}
return newList;
});
};
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setCurrentView('browse');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const allMedia = useMemo(() => {
// Merge mock media, adult media, detail media and custom media
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
list.push(DETAIL_MEDIA);
}
// Filter by active category AND ensure it's enabled
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
}, [activeCategory, enabledCategories, customMedia, adultMedia]);
const handleAddMedia = (newMedia: Media) => {
setCustomMedia(prev => [...prev, newMedia]);
};
const allStaff = useMemo(() => {
const staff: Staff[] = [];
// Use all available media (mock + adult + custom + detail) but filter by enabled categories
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
baseList.push(DETAIL_MEDIA);
}
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
enabledMedia.forEach(media => {
media.staff?.forEach(s => {
staff.push({
...s,
mediaId: media.id,
mediaTitle: media.title
});
});
});
return staff;
}, [enabledCategories, customMedia, adultMedia]);
const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia;
const query = searchQuery.toLowerCase();
return allMedia.filter(media =>
media.title.toLowerCase().includes(query) ||
media.year.toLowerCase().includes(query) ||
media.genres?.some(g => g.toLowerCase().includes(query)) ||
media.studios?.some(s => s.toLowerCase().includes(query))
);
}, [allMedia, searchQuery]);
const handleMediaClick = async (media: Media) => {
// For adult media, try to fetch detailed data by ID
if (media.category === 'Adult') {
try {
const detailedMedia = await fetchMediaById(parseInt(media.id));
if (detailedMedia) {
setSelectedMedia(detailedMedia);
} else {
// Fallback to original media if detailed fetch fails
setSelectedMedia(media);
}
} catch (error) {
console.error('Failed to fetch detailed media:', error);
setSelectedMedia(media);
}
} else {
// For non-adult media, use the original media
setSelectedMedia(media);
}
setCurrentView('detail');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => {
setCurrentView('browse');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCastClick = () => {
setCurrentView('cast');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePersonClick = (person: Staff) => {
// Enrich person with some mock data for the detail page
const enrichedPerson: Staff = {
...person,
bio: `${person.name} is a renowned ${person.role} with a career spanning over a decade. Known for their versatility and emotional depth, they have become a staple in the industry, particularly for their work in ${person.mediaTitle || 'major productions'}.`,
birthDate: 'October 14, 1985',
birthPlace: 'Tokyo, Japan',
occupations: ['Voice Actor', 'Singer', 'Narrator']
};
setSelectedPerson(enrichedPerson);
setCurrentView('castDetail');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleSearch = (query: string) => {
setSearchQuery(query);
if (currentView !== 'browse' && currentView !== 'cast') {
setCurrentView('browse');
}
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onBrowse={handleBack}
onCast={handleCastClick}
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={currentView === 'detail' || currentView === 'castDetail'}
/>
<main>
<LayoutGroup>
{currentView === 'browse' ? (
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
onAddMedia={handleAddMedia}
activeCategory={activeCategory}
/>
) : currentView === 'cast' ? (
<CastView
staffList={allStaff}
onPersonClick={handlePersonClick}
/>
) : currentView === 'castDetail' ? (
selectedPerson && (
<CastDetailView
person={selectedPerson}
onBack={handleCastClick}
onMediaClick={(id) => {
const media = allMedia.find(m => m.id === id);
if (media) handleMediaClick(media);
}}
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
/>
)
) : (
selectedMedia && (
<DetailView
media={selectedMedia}
onBack={handleBack}
onPersonClick={handlePersonClick}
/>
)
)}
</LayoutGroup>
</main>
{/* Footer */}
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2 text-xl font-black text-zinc-400">
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
kyoo
</div>
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
</div>
<p className="text-xs font-medium text-zinc-400">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</div>
</footer>
</div>
);
}

290
src/api.ts Normal file
View File

@@ -0,0 +1,290 @@
import { Media, Staff } from './types';
const BASE_URL = 'http://192.168.1.102:57000';
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove leading slash if present and add base URL
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
export interface ApiResponse {
success: boolean;
data: {
items: ApiMediaItem[];
};
}
export interface ApiMediaItem {
id: number;
title: string;
overview: string;
poster_url: string;
poster_aspect_ratio: string | null;
backdrop_url: string | null;
backdrop_aspect_ratio: string | null;
rating: string;
runtime_minutes: number;
release_date: string;
director: string | null;
writer: string | null;
cast: string | null;
genre: string | null;
metadata: string;
actors?: Array<{
id: number;
name: string;
thumbnail_path: string | null;
metadata?: string;
created_at?: string;
updated_at?: string;
}>;
}
export interface ApiMetadata {
xbvr_id: number;
xbvr_url: string | null;
cast: string[];
actors: Array<{
id: number;
name: string;
thumbnail_path: string | null;
}>;
tags: string[];
is_available: boolean;
is_watched: boolean;
watch_count: number;
video_length: number;
video_width: number | null;
video_height: number | null;
video_codec: string | null;
file_path: string | null;
cover_url: string;
[key: string]: any;
}
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
let metadata: ApiMetadata;
try {
metadata = JSON.parse(apiItem.metadata);
} catch (e) {
metadata = {
xbvr_id: 0,
xbvr_url: null,
cast: [],
actors: [],
tags: [],
is_available: false,
is_watched: false,
watch_count: 0,
video_length: 0,
video_width: null,
video_height: null,
video_codec: null,
file_path: null,
cover_url: apiItem.poster_url,
};
}
// Use actors from the main item if available, otherwise from metadata
const actors = apiItem.actors || metadata.actors || [];
const staff: Staff[] = actors.map((actor, index) => ({
id: `actor-${actor.id}`,
name: actor.name,
role: 'Actor',
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
characterName: actor.name,
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
}));
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.poster_aspect_ratio) {
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('1.78')) {
aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
aspectRatio = '1/1';
}
}
return {
id: apiItem.id.toString() || undefined,
title: apiItem.title || undefined,
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
description: apiItem.overview || undefined,
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
genres: metadata.tags || [],
tags: metadata.tags || [],
studios: apiItem.director ? [apiItem.director] : undefined,
type: 'Movie',
status: 'completed',
staff: staff.length > 0 ? staff : undefined,
runtime: apiItem.runtime_minutes,
director: apiItem.director || undefined,
writer: apiItem.writer || undefined,
releaseDate: apiItem.release_date || undefined,
aspectRatio: aspectRatio
};
}
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
console.error('Error fetching');
try {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from API:', error);
return [];
}
}
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from local JSON:', error);
return [];
}
}
export async function fetchMediaById(id: number): Promise<Media | null> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const item = data.data.items.find(item => item.id === id);
return item ? convertApiToMedia(item) : null;
}
return null;
} catch (error) {
console.error('Error fetching media by ID:', error);
return null;
}
}
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => {
try {
const metadata = JSON.parse(item.metadata);
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
} catch {
return false;
}
})
.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media by tag:', error);
return [];
}
}
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const actorMap = new Map();
data.data.items.forEach(item => {
item.actors?.forEach(actor => {
if (!actorMap.has(actor.id)) {
actorMap.set(actor.id, {
id: actor.id,
name: actor.name,
thumbnail_path: actor.thumbnail_path
});
}
});
});
return Array.from(actorMap.values());
}
return [];
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
export async function fetchAllTags(): Promise<string[]> {
try {
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const tagSet = new Set<string>();
data.data.items.forEach(item => {
try {
const metadata = JSON.parse(item.metadata);
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
} catch {
// Ignore metadata parsing errors
}
});
return Array.from(tagSet).sort();
}
return [];
} catch (error) {
console.error('Error fetching all tags:', error);
return [];
}
}

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

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
src/components/CastView.tsx Normal file
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>
);
}

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
src/components/Header.tsx Normal file
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>
);
}

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

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

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

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 }

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 }

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,
}

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,
}

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 }

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 }

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 }

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 }

222
src/data.ts Normal file
View File

@@ -0,0 +1,222 @@
import { Media } from './types';
export const MOCK_MEDIA: Media[] = [
{
id: '1',
title: 'Journal with Witch',
year: '2026',
poster: 'https://picsum.photos/seed/witch/400/600',
status: 'watching',
category: 'Anime',
type: 'TV',
genres: ['Fantasy', 'Adventure'],
studios: ['Studio Bind'],
staff: [
{ id: 's101', name: 'Aoi Koga', role: 'Elaina', photo: 'https://picsum.photos/seed/aoi/200/200', characterName: 'Elaina', characterImage: 'https://picsum.photos/seed/elaina/200/200' }
]
},
{
id: '2',
title: "Takopi's Original Sin",
year: '2025',
poster: 'https://picsum.photos/seed/takopi/400/600',
status: 'completed',
category: 'Anime',
type: 'ONA',
genres: ['Drama', 'Psychological'],
studios: ['MAPPA'],
staff: [
{ id: 's102', name: 'Hina Kino', role: 'Takopi', photo: 'https://picsum.photos/seed/hina/200/200', characterName: 'Takopi', characterImage: 'https://picsum.photos/seed/takopi_char/200/200' }
]
},
{
id: '3',
title: 'Arcane',
year: '2021 - 2024',
poster: 'https://picsum.photos/seed/arcane/400/600',
status: 'watching',
category: 'Anime',
type: 'TV',
genres: ['Action', 'Adventure', 'Fantasy'],
studios: ['Fortiche'],
staff: [
{ id: 's103', name: 'Hailee Steinfeld', role: 'Vi', photo: 'https://picsum.photos/seed/hailee/200/200', characterName: 'Vi', characterImage: 'https://picsum.photos/seed/vi/200/200' },
{ id: 's104', name: 'Ella Purnell', role: 'Jinx', photo: 'https://picsum.photos/seed/ella/200/200', characterName: 'Jinx', characterImage: 'https://picsum.photos/seed/jinx/200/200' }
]
},
{
id: 'm1',
title: 'Inception',
year: '2010',
poster: 'https://picsum.photos/seed/inception/400/600',
status: 'completed',
category: 'Movies',
type: 'Movie',
genres: ['Sci-Fi', 'Action', 'Thriller'],
studios: ['Warner Bros.'],
staff: [
{ id: 's201', name: 'Leonardo DiCaprio', role: 'Cobb', photo: 'https://picsum.photos/seed/leo/200/200', characterName: 'Cobb', characterImage: 'https://picsum.photos/seed/cobb/200/200' },
{ id: 's202', name: 'Joseph Gordon-Levitt', role: 'Arthur', photo: 'https://picsum.photos/seed/joseph/200/200', characterName: 'Arthur', characterImage: 'https://picsum.photos/seed/arthur/200/200' }
]
},
{
id: 'mu1',
title: 'Random Access Memories',
year: '2013',
poster: 'https://picsum.photos/seed/daftpunk/400/600',
status: 'listening',
category: 'Music',
aspectRatio: '1/1',
type: 'Album',
genres: ['Electronic', 'Funk', 'Disco'],
studios: ['Columbia Records'],
staff: [
{ id: 's301', name: 'Thomas Bangalter', role: 'Artist', photo: 'https://picsum.photos/seed/thomas/200/200', characterName: 'Daft Punk', characterImage: 'https://picsum.photos/seed/dp1/200/200' },
{ id: 's302', name: 'Guy-Manuel', role: 'Artist', photo: 'https://picsum.photos/seed/guy/200/200', characterName: 'Daft Punk', characterImage: 'https://picsum.photos/seed/dp2/200/200' }
]
},
{
id: 'b1',
title: 'The Great Gatsby',
year: '1925',
poster: 'https://picsum.photos/seed/gatsby/400/600',
status: 'reading',
category: 'Books',
type: 'Hardcover',
genres: ['Classic', 'Fiction'],
studios: ['Scribner'],
staff: [
{ id: 's401', name: 'F. Scott Fitzgerald', role: 'Author', photo: 'https://picsum.photos/seed/fitzgerald/200/200', characterName: 'Writer', characterImage: 'https://picsum.photos/seed/writer/200/200' }
]
},
{
id: 'c1',
title: 'PlayStation 5',
year: '2020',
poster: 'https://picsum.photos/seed/ps5/400/600',
status: 'playing',
category: 'Consoles',
type: 'Console',
genres: ['Gaming', 'Hardware'],
studios: ['Sony Interactive Entertainment'],
},
{
id: 'g1',
title: 'Elden Ring',
year: '2022',
poster: 'https://picsum.photos/seed/eldenring/400/600',
status: 'playing',
category: 'Games',
aspectRatio: '16/9',
type: 'Game',
genres: ['Action RPG', 'Open World'],
studios: ['FromSoftware'],
staff: [
{ id: 's501', name: 'Hidetaka Miyazaki', role: 'Director', photo: 'https://picsum.photos/seed/miyazaki/200/200', characterName: 'Creator', characterImage: 'https://picsum.photos/seed/creator/200/200' }
]
},
{
id: 'av1',
title: 'Adult Content Example',
year: '2024',
poster: 'https://picsum.photos/seed/adult/400/600',
status: 'planned',
category: 'Adult',
type: 'Movie',
genres: ['Adult', 'Romance'],
studios: ['Example Studio'],
}
];
export const DETAIL_MEDIA: Media = {
id: 'mob-psycho',
title: 'Mob Psycho 100',
year: '2016 - 2022',
category: 'Anime',
poster: 'https://picsum.photos/seed/mob-poster/400/600',
banner: 'https://picsum.photos/seed/mob-banner/1920/1080',
rating: 8.5,
description: "Shigeo Kageyama, a.k.a. 'Mob,' is a boy who has trouble expressing himself, but who happens to be a powerful esper. Mob is determined to live a normal life and keeps his ESP suppressed, but when his emotions surge to a level of 100%, something terrible happens to him! As he's surrounded by false espers, evil spirits, and mysterious organizations, what will Mob think? What choices will he make?",
genres: ['Animation', 'Action', 'Adventure', 'Comedy', 'Science Fiction', 'Fantasy'],
tags: ['Supernatural', 'Based On Comic', 'Psychic Power', 'Slice Of Life', 'Coming Of Age', 'Sibling Rivalry', 'Based On Manga', 'Super Power', 'Occult', 'Extrasensory Perception', 'High School Rivalry', 'School Life', 'Shounen', 'Anime', 'Japanese High School', 'Based On Webcomic Or Webtoon', 'Sentimental', 'Amused', 'Cheerful', 'Empathetic', 'Enthusiastic', 'Gentle', 'Hopeful', 'Optimistic', 'Straightforward', 'Sympathetic'],
studios: ['BONES', 'BS Fuji', 'Hakuhodo DY Music & Pictures', 'Warner Bros. Japan', 'THE KLOCKWORX', 'Shogakukan', 'Shogakukan-Shueisha Productions'],
staff: [
{
id: 's1',
name: 'Takahiro Sakurai',
role: 'Arataka Reigen',
photo: 'https://picsum.photos/seed/staff1/200/200',
characterName: 'Arataka Reigen',
characterImage: 'https://picsum.photos/seed/char1/200/200',
},
{
id: 's2',
name: 'Setsuo Itou',
role: 'Shigeo Kageyama',
photo: 'https://picsum.photos/seed/staff2/200/200',
characterName: 'Shigeo Kageyama',
characterImage: 'https://picsum.photos/seed/char2/200/200',
},
{
id: 's3',
name: 'Akio Otsuka',
role: 'Ekubo',
photo: 'https://picsum.photos/seed/staff3/200/200',
characterName: 'Ekubo',
characterImage: 'https://picsum.photos/seed/char3/200/200',
},
{
id: 's4',
name: 'Miyu Irino',
role: 'Ritsu Kageyama',
photo: 'https://picsum.photos/seed/staff4/200/200',
characterName: 'Ritsu Kageyama',
characterImage: 'https://picsum.photos/seed/char4/200/200',
},
{
id: 's5',
name: 'Yoshimasa Hosoya',
role: 'Tenga Onigawara',
photo: 'https://picsum.photos/seed/staff5/200/200',
characterName: 'Tenga Onigawara',
characterImage: 'https://picsum.photos/seed/char5/200/200',
},
{
id: 's6',
name: 'Atsumi Tanezaki',
role: 'Tome Kurata',
photo: 'https://picsum.photos/seed/staff6/200/200',
characterName: 'Tome Kurata',
characterImage: 'https://picsum.photos/seed/char6/200/200',
},
],
episodes: [
{
id: 'e1',
number: 1,
title: 'Self-Proclaimed Psychic: Reigen Arataka ~And Mob~',
date: '7/12/2016',
duration: '24min',
description: 'Psychics fight with supernatural phenomena that cannot be explained by science. Reigen Arataka that runs the Spirits and Such Consultation office is one of those psychics... well, a fake one. His only hope is a super dull middle-schooler named Kageyama Shigeo, a.k.a. Mob. 27% towards Mob\'s explosion.',
thumbnail: 'https://picsum.photos/seed/ep1/400/225',
},
{
id: 'e2',
number: 2,
title: 'Doubts about Youth ~The Telepathy Club Appears~',
date: '7/19/2016',
duration: '24min',
description: 'The Telepathy Club is told that they are going to be shut down by the student council due to lack of members. It was then that one of the members, Inukawa, thought of possibly getting Mob to join. However, Mob gets a call from Reigen about taking care of an incident at Highso Girls\' Academy...',
thumbnail: 'https://picsum.photos/seed/ep2/400/225',
},
{
id: 'e3',
number: 3,
title: 'An Invitation to a Meeting ~Simply Put, I Just Want to be Popular~',
date: '7/26/2016',
duration: '24min',
description: 'It\'s great that Mob joined the Body Improvement Club, but he suddenly faints as he\'s running along the riverside. His wish is to get stronger and to be able to get closer to his beloved Tsubomi-chan. One day, as Mob is feeling troubled, a woman with a strange mask approaches him... Progress toward Mob\'s explosion: 20%',
thumbnail: 'https://picsum.photos/seed/ep3/400/225',
},
]
};

141
src/index.css Normal file
View File

@@ -0,0 +1,141 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
body {
@apply font-sans antialiased text-zinc-900;
}
}
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

45
src/types.ts Normal file
View File

@@ -0,0 +1,45 @@
export type MediaCategory = 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
export interface Media {
id: string;
title: string;
year: string;
poster: string;
category: MediaCategory;
aspectRatio?: '2/3' | '16/9' | '1/1';
type?: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game';
banner?: string;
description?: string;
rating?: number;
genres?: string[];
tags?: string[];
studios?: string[];
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
episodes?: Episode[];
staff?: Staff[];
}
export interface Episode {
id: string;
number: number;
title: string;
date: string;
duration: string;
description: string;
thumbnail: string;
}
export interface Staff {
id: string;
name: string;
role: string;
photo: string;
characterName: string;
characterImage: string;
mediaId?: string;
mediaTitle?: string;
bio?: string;
birthDate?: string;
birthPlace?: string;
occupations?: string[];
}