media_key was a lossy shortening of item_key (libraryId:lastSegment) that introduced a real collision bug: two TV episodes from different series with the same filename would share the same media_key and each other's tags. - DB migration converts existing media_tags rows from short format to full item_key by joining against media_items; ambiguous/orphaned rows are dropped - media_tags column renamed media_key → item_key - Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes item_key directly to reKeyMediaItem - DB reader functions (tv, movies, games) now expose item_key on returned entities; frontend components use entity.item_key instead of constructing the short libraryId:id form - MixedView now constructs the full mixed_file: item_key format - Tag API renamed mediaKey param → itemKey throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
import type { Game, GameSeries } from '@/types'
|
|
import GameDetailModal from './GameDetailModal'
|
|
import FilterPanel from '@/components/FilterPanel'
|
|
|
|
interface Props {
|
|
libraryId: string
|
|
}
|
|
|
|
export default function GamesView({ libraryId }: Props) {
|
|
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [selectedSeries, setSelectedSeries] = useState<GameSeries | null>(null)
|
|
const [selected, setSelected] = useState<Game | null>(null)
|
|
const selectedRef = useRef(selected)
|
|
selectedRef.current = selected
|
|
const [search, setSearch] = useState('')
|
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
|
const [showFilters, setShowFilters] = useState(true)
|
|
|
|
const toggleTag = (tagId: string) =>
|
|
setSelectedTagIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
|
return next
|
|
})
|
|
|
|
const fetchGames = useCallback((syncSelected = false) => {
|
|
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
|
.then((r) => r.json())
|
|
.then((data: (Game | GameSeries)[]) => {
|
|
setItems(data)
|
|
setLoading(false)
|
|
if (syncSelected && selectedRef.current) {
|
|
const id = selectedRef.current.id
|
|
// Search top-level games and inside series
|
|
let updated: Game | undefined
|
|
for (const item of data) {
|
|
if ('games' in item) {
|
|
updated = item.games.find((g) => g.id === id)
|
|
} else if (item.id === id) {
|
|
updated = item
|
|
}
|
|
if (updated) break
|
|
}
|
|
if (updated) setSelected(updated)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setError('Failed to load games')
|
|
setLoading(false)
|
|
})
|
|
}, [libraryId])
|
|
|
|
useEffect(() => { fetchGames() }, [fetchGames])
|
|
|
|
const fetchAssignments = useCallback(() => {
|
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
|
.then((r) => r.json())
|
|
.then(setAssignments)
|
|
.catch(() => {})
|
|
}, [libraryId])
|
|
|
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
|
|
|
// Items shown in the current view level
|
|
const visibleItems: (Game | GameSeries)[] = selectedSeries
|
|
? selectedSeries.games
|
|
: items
|
|
|
|
const filtered = visibleItems.filter((item) => {
|
|
if ('games' in item) {
|
|
const searchMatch = !search ||
|
|
item.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
|
|
if (!searchMatch) return false
|
|
if (selectedTagIds.size > 0) {
|
|
return item.games.some((g) => {
|
|
const gameTags = assignments[g.item_key!] ?? []
|
|
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
|
if (selectedTagIds.size > 0) {
|
|
const gameTags = assignments[item.item_key!] ?? []
|
|
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<button
|
|
onClick={() => setShowFilters((v) => !v)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
style={{
|
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
|
>
|
|
Filters{filtersActive ? ' ●' : ''}
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
|
{showFilters && (
|
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
|
<FilterPanel
|
|
libraryId={libraryId}
|
|
assignments={assignments}
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
selectedTagIds={selectedTagIds}
|
|
onTagToggle={toggleTag}
|
|
refreshKey={filterRefreshKey}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Breadcrumb when inside a series */}
|
|
{selectedSeries && (
|
|
<div className="flex items-center gap-2 mb-4 text-sm">
|
|
<button
|
|
onClick={() => { setSelectedSeries(null); setSearch('') }}
|
|
className="transition-colors"
|
|
style={{ color: 'var(--accent)' }}
|
|
>
|
|
All Games
|
|
</button>
|
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
{selectedSeries.title}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<LoadingGrid />
|
|
) : error ? (
|
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
{error}
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
<p className="text-lg mb-1">No games found</p>
|
|
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{filtered.map((item) =>
|
|
'games' in item ? (
|
|
<SeriesCard
|
|
key={item.id}
|
|
series={item}
|
|
onClick={() => { setSelectedSeries(item); setSearch('') }}
|
|
/>
|
|
) : (
|
|
<GameCard
|
|
key={item.id}
|
|
game={item}
|
|
onClick={() => setSelected(item)}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selected && (
|
|
<GameDetailModal
|
|
game={selected}
|
|
libraryId={libraryId}
|
|
onClose={() => setSelected(null)}
|
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
|
onCoverUploaded={() => fetchGames(true)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
|
onMouseEnter={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
|
}}
|
|
>
|
|
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
|
{game.coverUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={game.coverUrl} alt={game.title} className="absolute inset-0 w-full h-full object-cover" />
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
|
)}
|
|
</div>
|
|
<div className="p-2">
|
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
|
|
{game.title}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
|
onMouseEnter={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
|
}}
|
|
>
|
|
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
|
{series.coverUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
|
)}
|
|
{/* Game count badge */}
|
|
<div
|
|
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
|
>
|
|
{series.games.length}
|
|
</div>
|
|
</div>
|
|
<div className="p-2">
|
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={series.title}>
|
|
{series.title}
|
|
</p>
|
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
|
Series
|
|
</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function LoadingGrid() {
|
|
return (
|
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div key={i} className="rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
|
<div className="aspect-[3/4] w-full animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
|
<div className="p-2">
|
|
<div className="h-3 rounded animate-pulse" style={{ backgroundColor: 'var(--border)', width: '70%' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|