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:
@@ -1,9 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import type { Game, GameFile, GamePlatform } from '@/types'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
|
||||
const PLATFORM_LABELS: Record<GamePlatform, string> = {
|
||||
windows: 'WIN',
|
||||
linux: 'LIN',
|
||||
macos: 'MAC',
|
||||
}
|
||||
const PLATFORM_COLORS: Record<GamePlatform, string> = {
|
||||
windows: '#0078d4',
|
||||
linux: '#e95420',
|
||||
macos: '#6e6e73',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
game: Game
|
||||
libraryId: string
|
||||
@@ -59,8 +70,16 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const zipDownloadUrl = (zipPath: string) =>
|
||||
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}`
|
||||
const [clientPlatform, setClientPlatform] = useState<GamePlatform | null>(null)
|
||||
useEffect(() => {
|
||||
const p = navigator.platform.toLowerCase()
|
||||
if (p.startsWith('win')) setClientPlatform('windows')
|
||||
else if (p.startsWith('mac') || p.includes('iphone') || p.includes('ipad')) setClientPlatform('macos')
|
||||
else setClientPlatform('linux')
|
||||
}, [])
|
||||
|
||||
const fileDownloadUrl = (filePath: string) =>
|
||||
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(filePath)}`
|
||||
const heroImage = game.wideCoverUrl ?? game.coverUrl
|
||||
|
||||
return (
|
||||
@@ -277,7 +296,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
||||
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
@@ -296,12 +315,25 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
|
||||
// ─── Download Button ──────────────────────────────────────────────────────────
|
||||
|
||||
function PlatformPill({ platform }: { platform: GamePlatform }) {
|
||||
return (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex-shrink-0"
|
||||
style={{ backgroundColor: PLATFORM_COLORS[platform], color: '#fff' }}
|
||||
>
|
||||
{PLATFORM_LABELS[platform]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadButton({
|
||||
zipFiles,
|
||||
gameFiles,
|
||||
clientPlatform,
|
||||
downloadUrl,
|
||||
}: {
|
||||
zipFiles: string[]
|
||||
downloadUrl: (zipPath: string) => string
|
||||
gameFiles: GameFile[]
|
||||
clientPlatform: GamePlatform | null
|
||||
downloadUrl: (filePath: string) => string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -317,13 +349,17 @@ function DownloadButton({
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open, close])
|
||||
|
||||
const primary = zipFiles[0]
|
||||
const primaryName = primary.split('/').pop() ?? primary
|
||||
if (gameFiles.length === 0) return null
|
||||
|
||||
if (zipFiles.length === 1) {
|
||||
// Pick primary: first file matching clientPlatform, or first overall
|
||||
const primary =
|
||||
(clientPlatform ? gameFiles.find((f) => f.platform === clientPlatform) : null) ??
|
||||
gameFiles[0]
|
||||
|
||||
if (gameFiles.length === 1) {
|
||||
return (
|
||||
<a
|
||||
href={downloadUrl(primary)}
|
||||
href={downloadUrl(primary.path)}
|
||||
download
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
@@ -331,7 +367,8 @@ function DownloadButton({
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||
>
|
||||
<span>↓</span>
|
||||
Download .zip
|
||||
<PlatformPill platform={primary.platform} />
|
||||
Download {primary.filename}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -341,15 +378,16 @@ function DownloadButton({
|
||||
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
|
||||
{/* Primary download */}
|
||||
<a
|
||||
href={downloadUrl(primary)}
|
||||
href={downloadUrl(primary.path)}
|
||||
download
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
|
||||
className="flex items-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors min-w-0"
|
||||
style={{ color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
<span>↓</span>
|
||||
{primaryName}
|
||||
<span className="flex-shrink-0">↓</span>
|
||||
<PlatformPill platform={primary.platform} />
|
||||
<span className="truncate">{primary.filename}</span>
|
||||
</a>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -358,7 +396,7 @@ function DownloadButton({
|
||||
{/* Dropdown toggle */}
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="px-3 flex items-center justify-center text-sm transition-colors"
|
||||
className="px-3 flex items-center justify-center text-sm transition-colors flex-shrink-0"
|
||||
style={{ color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
@@ -373,24 +411,22 @@ function DownloadButton({
|
||||
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{zipFiles.map((zipPath) => {
|
||||
const name = zipPath.split('/').pop() ?? zipPath
|
||||
return (
|
||||
<a
|
||||
key={zipPath}
|
||||
href={downloadUrl(zipPath)}
|
||||
download
|
||||
onClick={close}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>↓</span>
|
||||
{name}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
{gameFiles.map((file) => (
|
||||
<a
|
||||
key={file.path}
|
||||
href={downloadUrl(file.path)}
|
||||
download
|
||||
onClick={close}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||
>
|
||||
<span style={{ color: 'var(--text-secondary)' }} className="flex-shrink-0">↓</span>
|
||||
<PlatformPill platform={file.platform} />
|
||||
<span className="truncate">{file.filename}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
Reference in New Issue
Block a user