playnite init
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search } from 'lucide-react';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
@@ -28,18 +28,27 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
||||
// Filter states
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
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 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 filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
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;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedGenre, selectedStudio]);
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
@@ -110,7 +119,61 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(selectedGenre || selectedStudio) && (
|
||||
{/* Platform Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 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 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Monitor size={16} />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
||||
{allPlatforms.sort().map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 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 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Users size={16} />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
||||
{allDevelopers.sort().map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("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 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 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 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<FolderTree size={16} />
|
||||
{selectedCategory || 'Categories'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
|
||||
{allCategories.sort().map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
@@ -118,6 +181,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
||||
onClick={() => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
|
||||
@@ -116,10 +116,72 @@ export default function DetailView({ media, onBack, onPersonClick }: DetailViewP
|
||||
</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>
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</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>
|
||||
|
||||
@@ -4,10 +4,12 @@ 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';
|
||||
|
||||
export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: 'http://192.168.1.102:4080' });
|
||||
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({ url: 'http://192.168.1.102:10001' });
|
||||
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({ ip: 'localhost', apiToken: '', port: 19821 });
|
||||
const [progress, setProgress] = useState<ImportProgress>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
@@ -101,6 +103,29 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||
setProgress(result);
|
||||
};
|
||||
|
||||
const handlePlayniteImport = async () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Playnite API...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
});
|
||||
setImportLog([]);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
playniteConfig,
|
||||
addLog,
|
||||
(progressUpdate) => {
|
||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||
}
|
||||
);
|
||||
|
||||
setProgress(result);
|
||||
};
|
||||
|
||||
const resetImport = () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
@@ -320,10 +345,82 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future importers */}
|
||||
<div className="bg-zinc-50 border border-zinc-200 border-dashed rounded-xl p-6 flex flex-col items-center justify-center text-zinc-400">
|
||||
<Download size={32} className="mb-2" />
|
||||
<p className="text-sm font-medium">More importers coming soon</p>
|
||||
{/* Playnite Importer Card */}
|
||||
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-orange-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">Playnite</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 border-zinc-200"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 mb-4">
|
||||
Import games from your Playnite library via Bridge API.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playniteConfig.ip}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playniteConfig.port || 19821}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
placeholder="19821"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={playniteConfig.apiToken}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
|
||||
disabled={progress.stage !== 'idle'}
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||
placeholder="pb_your_token_here"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePlayniteImport}
|
||||
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white font-bold"
|
||||
>
|
||||
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} className="mr-2" />
|
||||
Import from Playnite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -392,9 +489,9 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||
<div className="bg-zinc-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Film size={16} className="text-zinc-400" />
|
||||
<span className="text-xs font-bold text-zinc-500">Videos</span>
|
||||
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-zinc-900">{progress.videosImported}</p>
|
||||
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
Reference in New Issue
Block a user