Add source field, UI filter, and import mapping
Introduce a 'source' property across types and API models, include it in convertApiToMedia, and add a Source input to AddMediaView. Add source-based filtering in BrowseView (dropdown + tag icon) and ensure Clear Filters resets source. Update playnite, stashapp, and xbvr importers to set source conditionally using a new SOURCE_CATEGORY_MAPPING constant (added to types) so sources are only applied for appropriate media categories.
This commit is contained in:
@@ -43,6 +43,7 @@ export interface ApiMediaItem {
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
releaseDate: string | null;
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
@@ -53,7 +54,6 @@ export interface ApiMediaItem {
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
source?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
@@ -87,6 +87,7 @@ export interface CreateMediaInput {
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
source?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
@@ -309,6 +310,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
source: apiItem.source || undefined,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
@@ -316,7 +318,6 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
source: apiItem.source,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '' as string,
|
||||
genres: '' as string,
|
||||
tags: '' as string,
|
||||
studios: '' as string
|
||||
@@ -129,6 +130,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
director: newMedia.director || null,
|
||||
writer: newMedia.writer || null,
|
||||
releaseDate: newMedia.releaseDate || null,
|
||||
source: newMedia.source || null,
|
||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
||||
@@ -163,6 +165,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
source: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: ''
|
||||
@@ -439,6 +442,16 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={newMedia.source}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
|
||||
placeholder="e.g. username, xbvr, stashapp"
|
||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cast/Staff Section */}
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
|
||||
@@ -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, Monitor, Users, FolderTree } from 'lucide-react';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
@@ -56,6 +57,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
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 allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
@@ -64,9 +66,10 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
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 (selectedSource && media.source !== selectedSource) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
@@ -208,7 +211,25 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
|
||||
{/* Source Filter */}
|
||||
{allSources.length > 0 && (
|
||||
<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", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
|
||||
<Tag size={16} />
|
||||
{selectedSource || 'Source'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
|
||||
{allSources.sort().map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
@@ -219,6 +240,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface PlayniteConfig {
|
||||
ip: string;
|
||||
apiToken: string;
|
||||
@@ -241,7 +244,7 @@ export async function importFromPlaynite(
|
||||
series: game.series ? [game.series] : [],
|
||||
ageRatings: game.ageRatings || [],
|
||||
regions: game.regions || [],
|
||||
source: game.source || null,
|
||||
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
|
||||
gameId: game.id,
|
||||
pluginId: null,
|
||||
completionStatus: game.completionStatus || 'Not Played',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface StashAPPConfig {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
@@ -702,6 +705,7 @@ export async function importFromStashAPP(
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
studios: [],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Import the source mapping
|
||||
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||
|
||||
export interface XBVRConfig {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
@@ -308,6 +311,7 @@ export async function importFromXBVR(
|
||||
director: null,
|
||||
writer: null,
|
||||
releaseDate: releaseDate,
|
||||
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
|
||||
genres: categories,
|
||||
tags: categories,
|
||||
studios: video.paysite?.name ? [video.paysite.name] : [],
|
||||
|
||||
@@ -109,3 +109,11 @@ export interface UserSettings {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Source to Category mapping - ensures sources are only used with appropriate categories
|
||||
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
|
||||
'xbvr': ['Adult'],
|
||||
'stashapp': ['Adult'],
|
||||
'playnite': ['Games'],
|
||||
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user