This commit is contained in:
Tickbase
2026-07-11 12:20:50 +02:00
parent e6ee8e7648
commit 6f369f7a3b
5 changed files with 137 additions and 18 deletions
@@ -0,0 +1,95 @@
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button, ButtonVariant } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
export interface UnlockerChoiceOption {
key: string
title: string
badge: 'recommended' | 'alternative'
description: string
buttonLabel: string
buttonVariant: ButtonVariant
onSelect: () => void
/** Omit entirely for unlockers that don't have community vote data (e.g. Epic's) */
votes?: GameVotes | null
}
export interface UnlockerChoiceDialogProps {
visible: boolean
gameTitle: string | null
onClose: () => void
options: UnlockerChoiceOption[]
}
/**
* Generic "choose which unlocker to install" dialog. Used for both the
* Steam choice (CreamLinux vs SmokeAPI, with vote data) and the Epic choice
* (ScreamAPI vs Koaloader, no votes) - they only differ in copy and options,
* not in structure.
*/
const UnlockerChoiceDialog = ({ visible, gameTitle, onClose, options }: UnlockerChoiceDialogProps) => {
return (
<Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="unlocker-selection-header">
<h3>Choose Unlocker</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="unlocker-selection-content">
<p className="game-title-info">
Select which unlocker to install for <strong>{gameTitle}</strong>:
</p>
<div className="unlocker-options">
{options.map((option) => (
<div
key={option.key}
className={`unlocker-option ${option.badge === 'recommended' ? 'recommended' : ''}`}
>
<div className="option-header">
<h4>{option.title}</h4>
<span
className={
option.badge === 'recommended' ? 'recommended-badge' : 'alternative-badge'
}
>
{option.badge === 'recommended' ? 'Recommended' : 'Alternative'}
</span>
</div>
<p className="option-description">{option.description}</p>
{option.votes !== undefined && <VotesDisplay votes={option.votes} />}
<Button variant={option.buttonVariant} onClick={option.onSelect} fullWidth>
{option.buttonLabel}
</Button>
</div>
))}
</div>
<div className="selection-info">
<Icon name={info} variant="solid" size="md" />
<span>
You can always uninstall and try the other option if one doesn't work properly.
</span>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default UnlockerChoiceDialog
+16 -1
View File
@@ -2,6 +2,8 @@ import { useMemo } from 'react'
import EpicGameItem from '@/components/games/EpicGameItem' import EpicGameItem from '@/components/games/EpicGameItem'
import { EpicGame } from '@/types/EpicGame' import { EpicGame } from '@/types/EpicGame'
import LoadingIndicator from '../common/LoadingIndicator' import LoadingIndicator from '../common/LoadingIndicator'
import { Button } from '@/components/buttons'
import { Icon, refresh } from '@/components/icons'
interface EpicGameListProps { interface EpicGameListProps {
games: EpicGame[] games: EpicGame[]
@@ -11,6 +13,7 @@ interface EpicGameListProps {
onUninstallScream: (game: EpicGame) => void onUninstallScream: (game: EpicGame) => void
onUninstallKoaloader: (game: EpicGame) => void onUninstallKoaloader: (game: EpicGame) => void
onSettings: (game: EpicGame) => void onSettings: (game: EpicGame) => void
onRefresh: () => void
} }
const EpicGameList = ({ const EpicGameList = ({
@@ -21,6 +24,7 @@ const EpicGameList = ({
onUninstallScream, onUninstallScream,
onUninstallKoaloader, onUninstallKoaloader,
onSettings, onSettings,
onRefresh,
}: EpicGameListProps) => { }: EpicGameListProps) => {
const sortedGames = useMemo( const sortedGames = useMemo(
() => [...games].sort((a, b) => a.title.localeCompare(b.title)), () => [...games].sort((a, b) => a.title.localeCompare(b.title)),
@@ -37,7 +41,18 @@ const EpicGameList = ({
return ( return (
<div className="game-list"> <div className="game-list">
<h2>Epic Games ({games.length})</h2> <div className="game-list-header">
<h2>Epic Games ({games.length})</h2>
<Button
variant="secondary"
size="medium"
onClick={onRefresh}
title="Refresh"
className="refresh-button"
leftIcon={<Icon name={refresh} variant="solid" size="md" />}
iconOnly
/>
</div>
{games.length === 0 ? ( {games.length === 0 ? (
<div className="no-games-message"> <div className="no-games-message">
+1 -10
View File
@@ -20,7 +20,6 @@ interface GameItemProps {
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => { const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null) const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
useEffect(() => { useEffect(() => {
// Function to fetch the game cover/image // Function to fetch the game cover/image
@@ -35,13 +34,9 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin
if (bestImageUrl) { if (bestImageUrl) {
setImageUrl(bestImageUrl) setImageUrl(bestImageUrl)
setHasError(false)
} else {
setHasError(true)
} }
} catch (error) { } catch (error) {
console.error('Error fetching game image:', error) console.error('Error fetching game image:', error)
setHasError(true)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -104,11 +99,7 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin
// Determine background image // Determine background image
const backgroundImage = const backgroundImage =
!isLoading && imageUrl !isLoading && imageUrl ? `url(${imageUrl})` : 'linear-gradient(135deg, #232323, #1A1A1A)'
? `url(${imageUrl})`
: hasError
? 'linear-gradient(135deg, #232323, #1A1A1A)'
: 'linear-gradient(135deg, #232323, #1A1A1A)'
return ( return (
<div <div
+16 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { GameItem, ImagePreloader } from '@/components/games' import { GameItem, ImagePreloader } from '@/components/games'
import { ActionType } from '@/components/buttons' import { ActionType, Button } from '@/components/buttons'
import { Icon, refresh } from '@/components/icons'
import { Game } from '@/types' import { Game } from '@/types'
import LoadingIndicator from '../common/LoadingIndicator' import LoadingIndicator from '../common/LoadingIndicator'
@@ -11,6 +12,7 @@ interface GameListProps {
onEdit?: (gameId: string) => void onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void onSmokeAPISettings?: (gameId: string) => void
onRate?: (gameId: string) => void onRate?: (gameId: string) => void
onRefresh: () => void
reportingEnabled?: boolean reportingEnabled?: boolean
} }
@@ -18,7 +20,7 @@ interface GameListProps {
* Main game list component * Main game list component
* Displays games in a grid with search and filtering applied * Displays games in a grid with search and filtering applied
*/ */
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => { const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, onRefresh, reportingEnabled }: GameListProps) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false) const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title // Sort games alphabetically by title
@@ -45,7 +47,18 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRa
return ( return (
<div className="game-list"> <div className="game-list">
<h2>Games ({games.length})</h2> <div className="game-list-header">
<h2>Games ({games.length})</h2>
<Button
variant="secondary"
size="medium"
onClick={onRefresh}
title="Refresh"
className="refresh-button"
leftIcon={<Icon name={refresh} className="icon-secondary" variant="solid" size="md" />}
iconOnly
/>
</div>
{!imagesPreloaded && games.length > 0 && ( {!imagesPreloaded && games.length > 0 && (
<ImagePreloader <ImagePreloader
+9 -4
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Game } from '@/types' import { Game } from '@/types'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
@@ -16,6 +16,7 @@ export function useGames() {
progress: 0, progress: 0,
}) })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasLoadedRef = useRef(false)
// LoadGames function outside of the useEffect to make it reusable // LoadGames function outside of the useEffect to make it reusable
const loadGames = useCallback(async () => { const loadGames = useCallback(async () => {
@@ -91,9 +92,13 @@ export function useGames() {
} }
} }
// Initialize event listeners and then load games // Initialize event listeners and then load games. Guarded by a ref
// (not isInitialLoad state) so this effect only ever runs once putting
// isInitialLoad in the dep array would re-subscribe the listeners the
// moment it flips to false, needlessly tearing down and recreating them.
setupEventListeners().then(() => { setupEventListeners().then(() => {
if (isInitialLoad) { if (!hasLoadedRef.current) {
hasLoadedRef.current = true
loadGames().catch(console.error) loadGames().catch(console.error)
} }
}) })
@@ -102,7 +107,7 @@ export function useGames() {
return () => { return () => {
unlisteners.forEach((fn) => fn()) unlisteners.forEach((fn) => fn())
} }
}, [loadGames, isInitialLoad]) }, [loadGames])
// Helper function to update a specific game in state // Helper function to update a specific game in state
const updateGame = useCallback((updatedGame: Game) => { const updateGame = useCallback((updatedGame: Game) => {