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:
@@ -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>
|
||||
))}
|
||||
|
||||
+24
-224
@@ -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,175 +150,26 @@ 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>
|
||||
<CastTab staff={media.staff} onPersonClick={onPersonClick} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
|
||||
{/* Seasons Tab */}
|
||||
{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>
|
||||
<SeasonsTab episodes={media.episodes} />
|
||||
)}
|
||||
|
||||
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */}
|
||||
{/* Tracks Tab */}
|
||||
{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>
|
||||
<TracksTab tracks={media.tracks} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 || '' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -147,6 +147,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
series: apiItem.series,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ApiMediaItem {
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
|
||||
@@ -27,6 +27,16 @@ export interface PlayniteConfig {
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for controlling the Playnite import process
|
||||
*/
|
||||
export interface PlayniteImportOptions {
|
||||
/** Maximum number of items to import (optional) */
|
||||
limit?: number;
|
||||
/** Filter items by name (case-insensitive, optional - for debugging) */
|
||||
nameFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
@@ -226,6 +236,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
* 5. Imports or updates each game in the Omnyx database
|
||||
*
|
||||
* @param config - Configuration for connecting to Playnite
|
||||
* @param options - Import options to control behavior
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
@@ -234,6 +245,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
* ```typescript
|
||||
* const progress = await importFromPlaynite(
|
||||
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
|
||||
* { limit: 10, nameFilter: 'Reside' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
@@ -242,6 +254,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
*/
|
||||
export async function importFromPlaynite(
|
||||
config: PlayniteConfig,
|
||||
options: PlayniteImportOptions,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
@@ -254,6 +267,8 @@ export async function importFromPlaynite(
|
||||
errors: []
|
||||
};
|
||||
|
||||
const { limit, nameFilter } = options;
|
||||
|
||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -265,10 +280,13 @@ export async function importFromPlaynite(
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [
|
||||
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
|
||||
m
|
||||
])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
@@ -276,7 +294,7 @@ export async function importFromPlaynite(
|
||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
@@ -286,22 +304,49 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||
const games = gamesData.games || [];
|
||||
let games = gamesData.games || [];
|
||||
|
||||
// Apply name filter if provided (case-insensitive)
|
||||
if (nameFilter) {
|
||||
const filterLower = nameFilter.toLowerCase();
|
||||
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
|
||||
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
|
||||
}
|
||||
|
||||
// Apply limit if provided (after name filter)
|
||||
if (limit && games.length > limit) {
|
||||
games = games.slice(0, limit);
|
||||
logCallback(`Limited to ${games.length} games`);
|
||||
}
|
||||
|
||||
logCallback(`Found ${games.length} games in Playnite`);
|
||||
|
||||
// Deduplicate games by name (case-insensitive, trimmed)
|
||||
const uniqueGamesMap = new Map<string, PlayniteGame>();
|
||||
for (const game of games) {
|
||||
const normalizedName = game.name.toLowerCase().trim();
|
||||
if (!uniqueGamesMap.has(normalizedName)) {
|
||||
uniqueGamesMap.set(normalizedName, game);
|
||||
}
|
||||
}
|
||||
const uniqueGames = Array.from(uniqueGamesMap.values());
|
||||
if (uniqueGames.length !== games.length) {
|
||||
logCallback(`Deduplicated: ${games.length} → ${uniqueGames.length} unique games`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch detailed information for each game
|
||||
progressCallback({
|
||||
total: games.length,
|
||||
total: uniqueGames.length,
|
||||
current: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Fetching game details...'
|
||||
});
|
||||
|
||||
const detailedGames: PlayniteGame[] = [];
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const game = games[i];
|
||||
for (let i = 0; i < uniqueGames.length; i++) {
|
||||
const game = uniqueGames[i];
|
||||
try {
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
|
||||
|
||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||
method: 'GET',
|
||||
@@ -355,9 +400,24 @@ export async function importFromPlaynite(
|
||||
for (let i = 0; i < detailedGames.length; i++) {
|
||||
const game = detailedGames[i];
|
||||
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
|
||||
const existingGame = existingMedia.get(cleanName);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
if (!isUpdate) {
|
||||
// Debug: show similar titles from database for games not found
|
||||
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
|
||||
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
|
||||
).slice(0, 5);
|
||||
if (similarTitles.length > 0) {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
|
||||
}
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
|
||||
}
|
||||
|
||||
// Skip if updateExisting is false and item already exists
|
||||
if (!config.updateExisting && isUpdate) {
|
||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||
|
||||
@@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books'
|
||||
export interface Media {
|
||||
id: string;
|
||||
title: string;
|
||||
cleanname?: string;
|
||||
year: string;
|
||||
poster: string;
|
||||
category: MediaCategory;
|
||||
@@ -19,6 +20,7 @@ export interface Media {
|
||||
tracks?: Track[];
|
||||
staff?: Staff[];
|
||||
categories?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
|
||||
Reference in New Issue
Block a user