Refactor detail tabs; add series & Playnite options

Split DetailView into focused tab components (Overview, Cast, Seasons, Tracks, Series) and moved related UI/logic into src/components/details/tabs/*. DetailView now composes these tabs and accepts allMedia for series lookups; MediaDetailRoute forwards allMedia.

Support for series was added across the stack: API types and converters now include series, Media type gained series and cleanname fields, and BrowseView now lists/filters by series (label updated to 'Series' and dropdown default changed to '--- Alle ---').

Playnite importer: introduced PlayniteImportOptions (limit, nameFilter), added UI inputs to ImporterView, increased existing media fetch limit, added name filtering, import limiting, deduplication and improved cleanname-based matching/logging. Adjusted progress/total handling to account for deduped items.
This commit is contained in:
Lars Behrends
2026-04-25 23:54:18 +02:00
parent 34bb4a27be
commit 9a72ba3064
13 changed files with 442 additions and 242 deletions
+4 -4
View File
@@ -58,7 +58,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.series || []))), [mediaList]);
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
const filteredMedia = useMemo(() => {
@@ -67,7 +67,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
if (selectedCategory && !media.series?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true;
});
@@ -201,11 +201,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<FolderTree size={16} />
{selectedCategory || 'Categories'}
{selectedCategory || 'Series'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>--- Alle ---</DropdownMenuItem>
{allCategories.sort().map(category => (
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
))}
+28 -228
View File
@@ -1,41 +1,29 @@
import { Media, Staff, Track } from '@/types';
import { Media, Staff } from '@/types';
import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react';
import {
Play,
Bookmark,
MoreHorizontal,
Star,
ChevronLeft,
ChevronRight,
Search,
ListFilter,
ChevronDown,
Calendar,
Clock,
Eye
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { ChevronLeft, Calendar, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { motion } from 'motion/react';
import OverviewTab from './details/tabs/OverviewTab';
import CastTab from './details/tabs/CastTab';
import SeasonsTab from './details/tabs/SeasonsTab';
import TracksTab from './details/tabs/TracksTab';
import SeriesTab from './details/tabs/SeriesTab';
interface DetailViewProps {
media: Media;
allMedia: Media[];
onPersonClick: (person: Staff) => void;
}
export default function DetailView({ media, onPersonClick }: DetailViewProps) {
export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) {
const navigate = useNavigate();
const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false);
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
const [progress, setProgress] = useState(70.8);
const hasEpisodes = media.episodes && media.episodes.length > 0;
const hasTracks = media.tracks && media.tracks.length > 0;
const hasCast = media.staff && media.staff.length > 0;
const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
const tabs = [
'Overview',
...(hasCast ? ['Cast'] : []),
@@ -43,6 +31,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
'History',
...(hasEpisodes ? ['Seasons'] : []),
...(hasTracks ? ['Tracks'] : []),
...(hasFranchise ? ['Series'] : []),
'Reviews',
'Suggestions',
'Watch On'
@@ -50,46 +39,6 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const [activeTab, setActiveTab] = useState(tabs[0]);
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!media.episodes) return {};
const grouped: Record<number, typeof media.episodes> = {};
media.episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [media.episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []);
const hasMoreCast = (media.staff?.length || 0) > castLimit;
return (
<div className="min-h-screen bg-background">
{/* Banner */}
@@ -201,176 +150,27 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
))}
</div>
{/* Genre Tags */}
{activeTab === 'Overview' && (
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
)}
{/* Overview Tab */}
{activeTab === 'Overview' && <OverviewTab media={media} />}
{/* Description */}
{activeTab === 'Overview' && (
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
)}
{/* Acting Section - Horizontal Scrollable */}
{/* Cast Tab */}
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
<CastTab staff={media.staff} onPersonClick={onPersonClick} />
)}
{/* Seasons Tab */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<SeasonsTab episodes={media.episodes} />
)}
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<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-2xl">
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).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-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-4">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={() => toggleSeason(season)}
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
>
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].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-2xl overflow-hidden shadow-sm relative border border-border/30">
<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 duration-300" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
E{episode.episode_number} {episode.title}
</h3>
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Tracks Tab */}
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<TracksTab tracks={media.tracks} />
)}
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<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-2xl">
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.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-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2">
{media.tracks.map(track => (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
</div>
))}
</div>
</section>
)}
{/* Series Tab */}
{media.category === 'Games' && media.series && media.series.length > 0 && activeTab === 'Series' && (
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(media) => window.location.href = `/${media.id}`} />
)}
</div>
</div>
</div>
+28 -1
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
import { importFromPlaynite, PlayniteConfig, PlayniteImportOptions } from '@/lib/playniteImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
import { fetchSettings, updateSettings } from '@/api';
@@ -25,6 +25,10 @@ export default function ImporterView() {
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
updateExisting: true
});
const [playniteOptions, setPlayniteOptions] = useState<PlayniteImportOptions>({
limit: undefined,
nameFilter: undefined
});
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
url: import.meta.env.VITE_JELLYFIN_URL || '',
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
@@ -199,6 +203,7 @@ export default function ImporterView() {
const result = await importFromPlaynite(
playniteConfig,
playniteOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
@@ -639,6 +644,28 @@ export default function ImporterView() {
/>
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
<input
type="number"
value={playniteOptions.limit || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. 10"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Name Filter (optional, for testing)</label>
<input
type="text"
value={playniteOptions.nameFilter || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, nameFilter: e.target.value || undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. Reside"
/>
</div>
<Button
onClick={handlePlayniteImport}
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
+46
View File
@@ -0,0 +1,46 @@
import { Staff } from '@/types';
import { useState } from 'react';
interface CastTabProps {
staff: Staff[];
onPersonClick: (person: Staff) => void;
}
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false);
const displayedCast = showAllCast ? staff : (staff?.slice(0, castLimit) || []);
const hasMoreCast = (staff?.length || 0) > castLimit;
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,27 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
interface OverviewTabProps {
media: Media;
}
export default function OverviewTab({ media }: OverviewTabProps) {
return (
<>
{/* Genre Tags */}
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
{/* Description */}
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { Episode } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import { Search, MoreHorizontal, ListFilter, ChevronDown } 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';
interface SeasonsTabProps {
episodes: Episode[];
}
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!episodes) return {};
const grouped: Record<number, typeof episodes> = {};
episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
return (
<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-2xl">
<span className="opacity-40">{episodes.length}</span> Episode{episodes.length !== 1 ? 's' : ''}
</div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).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-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-4">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={() => toggleSeason(season)}
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
>
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].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-2xl overflow-hidden shadow-sm relative border border-border/30">
<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 duration-300" />
</div>
<div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
E{episode.episode_number} {episode.title}
</h3>
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{episode.description}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))}
</div>
)}
</div>
))}
</div>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Media } from '@/types';
import MediaCard from '../../MediaCard';
interface SeriesTabProps {
media: Media;
allMedia: Media[];
onMediaClick: (media: Media) => void;
}
export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) {
// Filter games that share at least one series with the current game
const seriesGames = allMedia.filter(
(m) =>
m.category === 'Games' &&
m.id !== media.id &&
m.series &&
media.series &&
m.series.some((s) => media.series!.includes(s))
);
if (seriesGames.length === 0) {
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
<p className="text-muted-foreground">No other games found in the same series.</p>
</section>
);
}
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
<div className="flex flex-wrap gap-2 mb-6">
{media.series?.map((s) => (
<span
key={s}
className="px-3 py-1 bg-[#6d28d9]/10 text-[#6d28d9] border border-[#6d28d9]/30 rounded-full text-sm font-bold"
>
{s}
</span>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{seriesGames.map((game) => (
<MediaCard
key={game.id}
media={game}
onClick={() => onMediaClick(game)}
/>
))}
</div>
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Track } from '@/types';
import { Search, MoreHorizontal, ListFilter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface TracksTabProps {
tracks: Track[];
}
export default function TracksTab({ tracks }: TracksTabProps) {
return (
<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-2xl">
<span className="opacity-40">{tracks.length}</span> Track{tracks.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-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2">
{tracks.map(track => (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
</div>
))}
</div>
</section>
);
}
@@ -44,6 +44,7 @@ export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetai
return (
<DetailView
media={selectedMedia}
allMedia={allMedia}
onPersonClick={onPersonClick}
/>
);