Add multi-platform game support with per-OS download detection

- Detect Windows (.zip), Linux (.tar.gz), and macOS (.dmg / .app bundle) game archives during scan
- Store GameFile[] with platform metadata in DB instead of plain zipFiles[]
- Stream .app bundles as on-the-fly zip archives via archiver
- Show WIN/LIN/MAC platform badge pills on GameCard and SeriesCard
- Auto-select the download matching the user's OS in GameDetailModal
- Persist cover URL to DB immediately on upload (no re-scan needed)
- Backward-compatible: legacy zipFiles entries map to platform 'windows'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-12 09:47:09 -04:00
parent ebc35d7184
commit 53205d4a19
9 changed files with 1273 additions and 87 deletions

View File

@@ -1,10 +1,38 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game, GameSeries } from '@/types'
import type { Game, GamePlatform, GameSeries } from '@/types'
import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#0078d4',
linux: '#e95420',
macos: '#6e6e73',
}
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"
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
>
{PLATFORM_LABELS[p]}
</span>
))}
</div>
)
}
interface Props {
libraryId: string
}
@@ -218,6 +246,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
) : (
<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}>
@@ -229,6 +262,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
}
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}
@@ -250,7 +288,13 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
{/* Game count badge */}
{/* 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' }}