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:
Lars Behrends
2026-04-11 00:28:43 +02:00
parent f482807387
commit 53c6f5c555
7 changed files with 61 additions and 6 deletions
+13
View File
@@ -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') && (
+25 -3
View File
@@ -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