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;
|
director: string | null;
|
||||||
writer: string | null;
|
writer: string | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
|
source?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
@@ -53,7 +54,6 @@ export interface ApiMediaItem {
|
|||||||
platforms?: string[];
|
platforms?: string[];
|
||||||
developers?: string[];
|
developers?: string[];
|
||||||
completionStatus?: string;
|
completionStatus?: string;
|
||||||
source?: string;
|
|
||||||
playCount?: number;
|
playCount?: number;
|
||||||
lastActivity?: string | null;
|
lastActivity?: string | null;
|
||||||
playtime?: number;
|
playtime?: number;
|
||||||
@@ -87,6 +87,7 @@ export interface CreateMediaInput {
|
|||||||
director?: string | null;
|
director?: string | null;
|
||||||
writer?: string | null;
|
writer?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
|
source?: string | null;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
studios?: string[];
|
studios?: string[];
|
||||||
@@ -309,6 +310,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
tags: apiItem.tags || [],
|
tags: apiItem.tags || [],
|
||||||
studios: apiItem.studios,
|
studios: apiItem.studios,
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
|
source: apiItem.source || undefined,
|
||||||
status: mediaStatus,
|
status: mediaStatus,
|
||||||
staff: staff.length > 0 ? staff : undefined,
|
staff: staff.length > 0 ? staff : undefined,
|
||||||
aspectRatio: aspectRatio,
|
aspectRatio: aspectRatio,
|
||||||
@@ -316,7 +318,6 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
platforms: apiItem.platforms,
|
platforms: apiItem.platforms,
|
||||||
developers: apiItem.developers,
|
developers: apiItem.developers,
|
||||||
completionStatus: apiItem.completionStatus,
|
completionStatus: apiItem.completionStatus,
|
||||||
source: apiItem.source,
|
|
||||||
playCount: apiItem.playCount,
|
playCount: apiItem.playCount,
|
||||||
lastActivity: apiItem.lastActivity,
|
lastActivity: apiItem.lastActivity,
|
||||||
playtime: apiItem.playtime
|
playtime: apiItem.playtime
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
director: '',
|
director: '',
|
||||||
writer: '',
|
writer: '',
|
||||||
releaseDate: '',
|
releaseDate: '',
|
||||||
|
source: '' as string,
|
||||||
genres: '' as string,
|
genres: '' as string,
|
||||||
tags: '' as string,
|
tags: '' as string,
|
||||||
studios: '' as string
|
studios: '' as string
|
||||||
@@ -129,6 +130,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
director: newMedia.director || null,
|
director: newMedia.director || null,
|
||||||
writer: newMedia.writer || null,
|
writer: newMedia.writer || null,
|
||||||
releaseDate: newMedia.releaseDate || null,
|
releaseDate: newMedia.releaseDate || null,
|
||||||
|
source: newMedia.source || null,
|
||||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
|
||||||
@@ -163,6 +165,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
director: '',
|
director: '',
|
||||||
writer: '',
|
writer: '',
|
||||||
releaseDate: '',
|
releaseDate: '',
|
||||||
|
source: '',
|
||||||
genres: '',
|
genres: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
studios: ''
|
studios: ''
|
||||||
@@ -439,6 +442,16 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
|
|||||||
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Cast/Staff Section */}
|
||||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Media, MediaCategory } from '@/types';
|
import { Media, MediaCategory } from '@/types';
|
||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import MediaListItem from './MediaListItem';
|
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 { Button } from '@/components/ui/button';
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +49,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||||
|
|
||||||
// Extract unique values for filters
|
// Extract unique values for filters
|
||||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
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 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 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.categories || []))), [mediaList]);
|
||||||
|
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
||||||
|
|
||||||
const filteredMedia = useMemo(() => {
|
const filteredMedia = useMemo(() => {
|
||||||
return mediaList.filter(media => {
|
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 (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||||
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
||||||
|
if (selectedSource && media.source !== selectedSource) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
|
||||||
|
|
||||||
// Reset to first page when mediaList or filters change
|
// Reset to first page when mediaList or filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,7 +211,25 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
</DropdownMenu>
|
</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
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -219,6 +240,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
setSelectedPlatform(null);
|
setSelectedPlatform(null);
|
||||||
setSelectedDeveloper(null);
|
setSelectedDeveloper(null);
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
|
setSelectedSource(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
// Import the source mapping
|
||||||
|
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||||
|
|
||||||
export interface PlayniteConfig {
|
export interface PlayniteConfig {
|
||||||
ip: string;
|
ip: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
@@ -241,7 +244,7 @@ export async function importFromPlaynite(
|
|||||||
series: game.series ? [game.series] : [],
|
series: game.series ? [game.series] : [],
|
||||||
ageRatings: game.ageRatings || [],
|
ageRatings: game.ageRatings || [],
|
||||||
regions: game.regions || [],
|
regions: game.regions || [],
|
||||||
source: game.source || null,
|
source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
|
||||||
gameId: game.id,
|
gameId: game.id,
|
||||||
pluginId: null,
|
pluginId: null,
|
||||||
completionStatus: game.completionStatus || 'Not Played',
|
completionStatus: game.completionStatus || 'Not Played',
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
// Import the source mapping
|
||||||
|
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||||
|
|
||||||
export interface StashAPPConfig {
|
export interface StashAPPConfig {
|
||||||
url: string;
|
url: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -702,6 +705,7 @@ export async function importFromStashAPP(
|
|||||||
director: null,
|
director: null,
|
||||||
writer: null,
|
writer: null,
|
||||||
releaseDate: releaseDate,
|
releaseDate: releaseDate,
|
||||||
|
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
studios: [],
|
studios: [],
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
// Import the source mapping
|
||||||
|
import { SOURCE_CATEGORY_MAPPING } from '@/types';
|
||||||
|
|
||||||
export interface XBVRConfig {
|
export interface XBVRConfig {
|
||||||
url: string;
|
url: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -308,6 +311,7 @@ export async function importFromXBVR(
|
|||||||
director: null,
|
director: null,
|
||||||
writer: null,
|
writer: null,
|
||||||
releaseDate: releaseDate,
|
releaseDate: releaseDate,
|
||||||
|
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
|
||||||
genres: categories,
|
genres: categories,
|
||||||
tags: categories,
|
tags: categories,
|
||||||
studios: video.paysite?.name ? [video.paysite.name] : [],
|
studios: video.paysite?.name ? [video.paysite.name] : [],
|
||||||
|
|||||||
@@ -109,3 +109,11 @@ export interface UserSettings {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: 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