'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 = { windows: 'WIN', linux: 'LIN', macos: 'MAC', android: 'AND', } const PLATFORM_COLORS: Record = { windows: '#85c0ec', linux: '#efd27b', macos: '#b0b0b7', android: '#9ee0ca', } const PLATFORM_ICONS: Record = { 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 } function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) { if (platforms.length === 0) return null return (
{platforms.map((p) => ( {getPlatformIcon(p)} {PLATFORM_LABELS[p]} ))}
) } 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(null) const [selectedSeries, setSelectedSeries] = useState(null) const [selected, setSelected] = useState(null) const selectedRef = useRef(selected) selectedRef.current = selected const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 ) const [selectedGameIndex, setSelectedGameIndex] = useState(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 ( <>
{showFilters && (
)}
{/* Breadcrumb when inside a series */} {selectedSeries && (
/ {selectedSeries.title}
)} {loading ? ( ) : error ? (
{error}
) : items.length === 0 ? (

No games found

Each game should be a folder containing a .zip file.

) : (
{filtered.map((item) => 'games' in item ? ( { setSelectedSeries(item); setSearch('') }} /> ) : ( { setSelected(item); setSelectedGameIndex(filteredGames.indexOf(item)) }} /> ) )}
)} {selected && ( { 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() }} /> )}
) } function GameCard({ game, onClick }: { game: Game; onClick: () => void }) { return ( ) } 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 ( ) } function LoadingGrid() { return (
{Array.from({ length: 12 }).map((_, i) => (
))}
) }