This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/components/games/GamesView.tsx
2026-04-18 11:48:01 -04:00

369 lines
14 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game, GamePlatform, GameSeries } from '@/types'
import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
// Import SVG icons
import WindowsIcon from '@/app/icons/windows.svg'
import LinuxIcon from '@/app/icons/linux.svg'
import MacosIcon from '@/app/icons/mac.svg'
import AndroidIcon from '@/app/icons/android.svg'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
android: 'AND',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#85c0ec',
linux: '#efd27b',
macos: '#b0b0b7',
android: '#9ee0ca',
}
const PLATFORM_ICONS: Record<GamePlatform, string> = {
windows: (typeof WindowsIcon === 'string' ? WindowsIcon : (WindowsIcon as { src: string }).src),
linux: (typeof LinuxIcon === 'string' ? LinuxIcon : (LinuxIcon as { src: string }).src),
macos: (typeof MacosIcon === 'string' ? MacosIcon : (MacosIcon as { src: string }).src),
android: (typeof AndroidIcon === 'string' ? AndroidIcon : (AndroidIcon as { src: string }).src),
}
function getPlatformIcon(platform: GamePlatform) {
const src = PLATFORM_ICONS[platform]
if (!src) return null
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="" width={14} height={14} aria-hidden="true" />
}
function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
if (platforms.length === 0) return null
return (
<div className="flex gap-1 flex-wrap">
{platforms.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex items-center gap-1"
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
>
{getPlatformIcon(p)}
<span className="sr-only">{PLATFORM_LABELS[p]}</span>
</span>
))}
</div>
)
}
interface Props {
libraryId: string
readOnly?: boolean
}
export default function GamesView({ libraryId, readOnly }: 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(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const [selectedGameIndex, setSelectedGameIndex] = useState<number | null>(null)
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
const filteredGames: Game[] = filtered.flatMap((item) =>
'games' in item ? item.games : [item as Game]
)
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); setSelectedGameIndex(filteredGames.indexOf(item)) }}
/>
)
)}
</div>
)}
{selected && (
<GameDetailModal
game={selected}
libraryId={libraryId}
readOnly={readOnly}
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
: undefined}
onNext={selectedGameIndex !== null && selectedGameIndex < filteredGames.length - 1
? () => { const g = filteredGames[selectedGameIndex + 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex + 1) }
: undefined}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onCoverUploaded={() => fetchGames(true)}
onDeleted={() => {
setSelected(null)
setSelectedGameIndex(null)
fetchGames()
fetchAssignments()
}}
/>
)}
</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>
)}
{game.platforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={game.platforms} />
</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 }) {
// Compute union of platforms across all games in the series
const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)),
]
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>
)}
{/* Platform badges (bottom-left) */}
{seriesPlatforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={seriesPlatforms} />
</div>
)}
{/* Game count badge (bottom-right) */}
<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>
)
}