mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-01-29 14:52:51 -05:00
Initial changes
This commit is contained in:
164
src/components/games/GameItem.tsx
Normal file
164
src/components/games/GameItem.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
import { Game } from '@/types'
|
||||
import { ActionButton, ActionType, Button } from '@/components/buttons'
|
||||
|
||||
interface GameItemProps {
|
||||
game: Game
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual game card component
|
||||
* Displays game information and action buttons
|
||||
*/
|
||||
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Function to fetch the game cover/image
|
||||
const fetchGameImage = async () => {
|
||||
// First check if we already have it (to prevent flickering on re-renders)
|
||||
if (imageUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to find the best available image for this game
|
||||
const bestImageUrl = await findBestGameImage(game.id)
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl)
|
||||
setHasError(false)
|
||||
} else {
|
||||
setHasError(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error)
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (game.id) {
|
||||
fetchGameImage()
|
||||
}
|
||||
}, [game.id, imageUrl])
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native === true
|
||||
|
||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
||||
|
||||
// Check if this is a Proton game without API files
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||
|
||||
const handleCreamAction = () => {
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleSmokeAction = () => {
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
onEdit(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
? `url(${imageUrl})`
|
||||
: hasError
|
||||
? 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="game-item-overlay">
|
||||
<div className="game-badges">
|
||||
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
||||
{game.native ? 'Native' : 'Proton'}
|
||||
</span>
|
||||
{game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
|
||||
{game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
|
||||
</div>
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{shouldShowCream && (
|
||||
<ActionButton
|
||||
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
||||
isInstalled={!!game.cream_installed}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleCreamAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||
{shouldShowSmoke && (
|
||||
<ActionButton
|
||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||
isInstalled={!!game.smoke_installed}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleSmokeAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show message for Proton games without API files */}
|
||||
{isProtonNoApi && (
|
||||
<div className="api-not-found-message">
|
||||
<span>Steam API DLL not found</span>
|
||||
<Button
|
||||
variant="warning"
|
||||
size="small"
|
||||
onClick={() => onAction(game.id, 'install_smoke')}
|
||||
title="Attempt to scan again"
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
title="Manage DLCs"
|
||||
className="edit-button"
|
||||
>
|
||||
Manage DLCs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameItem
|
||||
71
src/components/games/GameList.tsx
Normal file
71
src/components/games/GameList.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {GameItem, ImagePreloader} from '@/components/games'
|
||||
import { ActionType } from '@/components/buttons'
|
||||
import { Game } from '@/types'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
|
||||
interface GameListProps {
|
||||
games: Game[]
|
||||
isLoading: boolean
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
const sortedGames = useMemo(() => {
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||
}, [games])
|
||||
|
||||
// Reset preloaded state when games change
|
||||
useEffect(() => {
|
||||
setImagesPreloaded(false)
|
||||
}, [games])
|
||||
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<LoadingIndicator
|
||||
type="spinner"
|
||||
size="large"
|
||||
message="Scanning for games..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Games ({games.length})</h2>
|
||||
|
||||
{!imagesPreloaded && games.length > 0 && (
|
||||
<ImagePreloader
|
||||
gameIds={sortedGames.map((game) => game.id)}
|
||||
onComplete={handlePreloadComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="no-games-message">No games found</div>
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameList
|
||||
61
src/components/games/ImagePreloader.tsx
Normal file
61
src/components/games/ImagePreloader.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
|
||||
interface ImagePreloaderProps {
|
||||
gameIds: string[]
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads game images to prevent flickering
|
||||
* Only used internally by GameList component
|
||||
*/
|
||||
const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
|
||||
useEffect(() => {
|
||||
const preloadImages = async () => {
|
||||
try {
|
||||
// Only preload the first batch for performance (10 images max)
|
||||
const batchToPreload = gameIds.slice(0, 10)
|
||||
|
||||
// Track loading progress
|
||||
let loadedCount = 0
|
||||
const totalImages = batchToPreload.length
|
||||
|
||||
// Load images in parallel
|
||||
await Promise.allSettled(
|
||||
batchToPreload.map(async (id) => {
|
||||
await findBestGameImage(id)
|
||||
loadedCount++
|
||||
|
||||
// If all images are loaded, call onComplete
|
||||
if (loadedCount === totalImages && onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Fallback if Promise.allSettled doesn't trigger onComplete
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preloading images:', error)
|
||||
// Continue even if there's an error
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
preloadImages()
|
||||
} else if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}, [gameIds, onComplete])
|
||||
|
||||
// Invisible component that just handles preloading
|
||||
return null
|
||||
}
|
||||
|
||||
export default ImagePreloader
|
||||
4
src/components/games/index.ts
Normal file
4
src/components/games/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Export all game components
|
||||
export { default as GameList } from './GameList';
|
||||
export { default as GameItem } from './GameItem';
|
||||
export { default as ImagePreloader } from './ImagePreloader';
|
||||
Reference in New Issue
Block a user