From 6f369f7a3be07a2b015098774af143a12c7bc0d0 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Sat, 11 Jul 2026 12:20:50 +0200 Subject: [PATCH] games --- .../dialogs/UnlockerChoiceDialog.tsx | 95 +++++++++++++++++++ src/components/games/EpicGameList.tsx | 17 +++- src/components/games/GameItem.tsx | 11 +-- src/components/games/GameList.tsx | 19 +++- src/hooks/useGames.ts | 13 ++- 5 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 src/components/dialogs/UnlockerChoiceDialog.tsx diff --git a/src/components/dialogs/UnlockerChoiceDialog.tsx b/src/components/dialogs/UnlockerChoiceDialog.tsx new file mode 100644 index 0000000..16e1f12 --- /dev/null +++ b/src/components/dialogs/UnlockerChoiceDialog.tsx @@ -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 ( + + +
+

Choose Unlocker

+
+
+ + +
+

+ Select which unlocker to install for {gameTitle}: +

+ +
+ {options.map((option) => ( +
+
+

{option.title}

+ + {option.badge === 'recommended' ? 'Recommended' : 'Alternative'} + +
+

{option.description}

+ {option.votes !== undefined && } + +
+ ))} +
+ +
+ + + You can always uninstall and try the other option if one doesn't work properly. + +
+
+
+ + + + + + +
+ ) +} + +export default UnlockerChoiceDialog diff --git a/src/components/games/EpicGameList.tsx b/src/components/games/EpicGameList.tsx index 9234f05..f0388e6 100644 --- a/src/components/games/EpicGameList.tsx +++ b/src/components/games/EpicGameList.tsx @@ -2,6 +2,8 @@ import { useMemo } from 'react' import EpicGameItem from '@/components/games/EpicGameItem' import { EpicGame } from '@/types/EpicGame' import LoadingIndicator from '../common/LoadingIndicator' +import { Button } from '@/components/buttons' +import { Icon, refresh } from '@/components/icons' interface EpicGameListProps { games: EpicGame[] @@ -11,6 +13,7 @@ interface EpicGameListProps { onUninstallScream: (game: EpicGame) => void onUninstallKoaloader: (game: EpicGame) => void onSettings: (game: EpicGame) => void + onRefresh: () => void } const EpicGameList = ({ @@ -21,6 +24,7 @@ const EpicGameList = ({ onUninstallScream, onUninstallKoaloader, onSettings, + onRefresh, }: EpicGameListProps) => { const sortedGames = useMemo( () => [...games].sort((a, b) => a.title.localeCompare(b.title)), @@ -37,7 +41,18 @@ const EpicGameList = ({ return (
-

Epic Games ({games.length})

+
+

Epic Games ({games.length})

+
{games.length === 0 ? (
diff --git a/src/components/games/GameItem.tsx b/src/components/games/GameItem.tsx index c2774e7..9317efa 100644 --- a/src/components/games/GameItem.tsx +++ b/src/components/games/GameItem.tsx @@ -20,7 +20,6 @@ interface GameItemProps { const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => { const [imageUrl, setImageUrl] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [hasError, setHasError] = useState(false) useEffect(() => { // Function to fetch the game cover/image @@ -35,13 +34,9 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin if (bestImageUrl) { setImageUrl(bestImageUrl) - setHasError(false) - } else { - setHasError(true) } } catch (error) { console.error('Error fetching game image:', error) - setHasError(true) } finally { setIsLoading(false) } @@ -104,11 +99,7 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin // Determine background image const backgroundImage = - !isLoading && imageUrl - ? `url(${imageUrl})` - : hasError - ? 'linear-gradient(135deg, #232323, #1A1A1A)' - : 'linear-gradient(135deg, #232323, #1A1A1A)' + !isLoading && imageUrl ? `url(${imageUrl})` : 'linear-gradient(135deg, #232323, #1A1A1A)' return (
void onSmokeAPISettings?: (gameId: string) => void onRate?: (gameId: string) => void + onRefresh: () => void reportingEnabled?: boolean } @@ -18,7 +20,7 @@ interface GameListProps { * Main game list component * 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) // Sort games alphabetically by title @@ -45,7 +47,18 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRa return (
-

Games ({games.length})

+
+

Games ({games.length})

+
{!imagesPreloaded && games.length > 0 && ( (null) + const hasLoadedRef = useRef(false) // LoadGames function outside of the useEffect to make it reusable 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(() => { - if (isInitialLoad) { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true loadGames().catch(console.error) } }) @@ -102,7 +107,7 @@ export function useGames() { return () => { unlisteners.forEach((fn) => fn()) } - }, [loadGames, isInitialLoad]) + }, [loadGames]) // Helper function to update a specific game in state const updateGame = useCallback((updatedGame: Game) => {