add series grouping, cover upload, and multi-zip download to games library
- Series grouping: a top-level folder with no .zip but game subfolders is now treated as a GameSeries. Clicking a series drills into it with a breadcrumb; a game-count badge distinguishes series cards from game cards. Series fall back to the first game's cover when no series-level cover exists. - Cover upload: new POST /api/game-cover endpoint writes cover.jpg or widecover.jpg directly into the game/series folder (re-encoded via sharp). A kebab menu on GameDetailModal opens an Edit Images panel showing previews and upload/replace buttons for both cover and wide cover. - Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip files in the folder. A single zip shows the existing download button; multiple zips render a split button — primary action downloads the first file, a dropdown arrow lists all files by name. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import type { Game, GameSeries } from '@/types'
|
||||
import GameDetailModal from './GameDetailModal'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
|
||||
@@ -10,10 +10,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function GamesView({ libraryId }: Props) {
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
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[]>>({})
|
||||
@@ -26,12 +29,26 @@ export default function GamesView({ libraryId }: Props) {
|
||||
return next
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGames = useCallback((syncSelected = false) => {
|
||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setGames(data)
|
||||
.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')
|
||||
@@ -39,6 +56,8 @@ export default function GamesView({ libraryId }: Props) {
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchGames() }, [fetchGames])
|
||||
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
@@ -48,10 +67,17 @@ export default function GamesView({ libraryId }: Props) {
|
||||
|
||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||
|
||||
const filtered = games.filter((game) => {
|
||||
if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||
// Items shown in the current view level
|
||||
const visibleItems: (Game | GameSeries)[] = selectedSeries
|
||||
? selectedSeries.games
|
||||
: items
|
||||
|
||||
const filtered = visibleItems.filter((item) => {
|
||||
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (selectedTagIds.size > 0) {
|
||||
const gameTags = assignments[`${libraryId}:${game.id}`] ?? []
|
||||
// Tag filtering only applies to games (series don't have tags directly)
|
||||
if ('games' in item) return true
|
||||
const gameTags = assignments[`${libraryId}:${item.id}`] ?? []
|
||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
||||
}
|
||||
return true
|
||||
@@ -71,57 +97,51 @@ export default function GamesView({ libraryId }: Props) {
|
||||
/>
|
||||
</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 ? (
|
||||
<ErrorMessage message={error} />
|
||||
) : games.length === 0 ? (
|
||||
<EmptyState />
|
||||
<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((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => setSelected(game)}
|
||||
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>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -131,6 +151,7 @@ export default function GamesView({ libraryId }: Props) {
|
||||
libraryId={libraryId}
|
||||
onClose={() => setSelected(null)}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
onCoverUploaded={() => fetchGames(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,6 +159,80 @@ export default function GamesView({ libraryId }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -152,20 +247,3 @@ function LoadingGrid() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user