Formatting

This commit is contained in:
Tickbase
2025-05-18 18:23:06 +02:00
parent bbbd7482c1
commit 81519e89b7
61 changed files with 714 additions and 775 deletions

View File

@@ -47,10 +47,10 @@ function StatusIndicator({ status }) {
status === 'success' status === 'success'
? 'Check' ? 'Check'
: status === 'warning' : status === 'warning'
? 'Warning' ? 'Warning'
: status === 'error' : status === 'error'
? 'Close' ? 'Close'
: 'Info' : 'Info'
return <Icon name={iconName} variant="bold" /> return <Icon name={iconName} variant="bold" />
} }

View File

@@ -27,9 +27,9 @@ function App() {
filteredGames, filteredGames,
handleRefresh, handleRefresh,
isLoading, isLoading,
error error,
} = useAppLogic({ autoLoad: true }) } = useAppLogic({ autoLoad: true })
// Get action handlers from context // Get action handlers from context
const { const {
dlcDialog, dlcDialog,
@@ -38,15 +38,12 @@ function App() {
progressDialog, progressDialog,
handleGameAction, handleGameAction,
handleDlcConfirm, handleDlcConfirm,
handleGameEdit handleGameEdit,
} = useAppContext() } = useAppContext()
// Show loading screen during initial load // Show loading screen during initial load
if (isInitialLoad) { if (isInitialLoad) {
return <InitialLoadingScreen return <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
message={scanProgress.message}
progress={scanProgress.progress}
/>
} }
return ( return (
@@ -56,17 +53,17 @@ function App() {
<AnimatedBackground /> <AnimatedBackground />
{/* Header with search */} {/* Header with search */}
<Header <Header
onRefresh={handleRefresh} onRefresh={handleRefresh}
onSearch={handleSearchChange} onSearch={handleSearchChange}
searchQuery={searchQuery} searchQuery={searchQuery}
refreshDisabled={isLoading} refreshDisabled={isLoading}
/> />
<div className="main-content"> <div className="main-content">
{/* Sidebar for filtering */} {/* Sidebar for filtering */}
<Sidebar setFilter={setFilter} currentFilter={filter} /> <Sidebar setFilter={setFilter} currentFilter={filter} />
{/* Show error or game list */} {/* Show error or game list */}
{error ? ( {error ? (
<div className="error-message"> <div className="error-message">
@@ -112,4 +109,4 @@ function App() {
) )
} }
export default App export default App

View File

@@ -39,14 +39,14 @@ const ActionButton: FC<ActionButtonProps> = ({
const getButtonVariant = (): ButtonVariant => { const getButtonVariant = (): ButtonVariant => {
// For uninstall actions, use danger variant // For uninstall actions, use danger variant
if (isInstalled) return 'danger' if (isInstalled) return 'danger'
// For install actions, use success variant // For install actions, use success variant
return 'success' return 'success'
} }
// Select appropriate icon based on action type and state // Select appropriate icon based on action type and state
const getIconInfo = () => { const getIconInfo = () => {
const isCream = action.includes('cream') const isCream = action.includes('cream')
if (isInstalled) { if (isInstalled) {
// Uninstall actions // Uninstall actions
return { name: layers, variant: 'bold' } return { name: layers, variant: 'bold' }
@@ -66,17 +66,13 @@ const ActionButton: FC<ActionButtonProps> = ({
disabled={disabled || isWorking} disabled={disabled || isWorking}
fullWidth fullWidth
className={`action-button ${className}`} className={`action-button ${className}`}
leftIcon={isWorking ? undefined : ( leftIcon={
<Icon isWorking ? undefined : <Icon name={iconInfo.name} variant={iconInfo.variant} size="md" />
name={iconInfo.name} }
variant={iconInfo.variant}
size="md"
/>
)}
> >
{getButtonText()} {getButtonText()}
</Button> </Button>
) )
} }
export default ActionButton export default ActionButton

View File

@@ -1,11 +1,11 @@
import { Icon, check } from '@/components/icons' import { Icon, check } from '@/components/icons'
interface AnimatedCheckboxProps { interface AnimatedCheckboxProps {
checked: boolean; checked: boolean
onChange: () => void; onChange: () => void
label?: string; label?: string
sublabel?: string; sublabel?: string
className?: string; className?: string
} }
/** /**
@@ -20,24 +20,12 @@ const AnimatedCheckbox = ({
}: AnimatedCheckboxProps) => { }: AnimatedCheckboxProps) => {
return ( return (
<label className={`animated-checkbox ${className}`}> <label className={`animated-checkbox ${className}`}>
<input <input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
type="checkbox"
checked={checked}
onChange={onChange}
className="checkbox-original"
/>
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}> <span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
{checked && ( {checked && <Icon name={check} variant="bold" size="sm" className="checkbox-icon" />}
<Icon
name={check}
variant="bold"
size="sm"
className="checkbox-icon"
/>
)}
</span> </span>
{(label || sublabel) && ( {(label || sublabel) && (
<div className="checkbox-content"> <div className="checkbox-content">
{label && <span className="checkbox-label">{label}</span>} {label && <span className="checkbox-label">{label}</span>}
@@ -48,4 +36,4 @@ const AnimatedCheckbox = ({
) )
} }
export default AnimatedCheckbox export default AnimatedCheckbox

View File

@@ -1,15 +1,15 @@
import { FC, ButtonHTMLAttributes } from 'react'; import { FC, ButtonHTMLAttributes } from 'react'
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning'
export type ButtonSize = 'small' | 'medium' | 'large'; export type ButtonSize = 'small' | 'medium' | 'large'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant
size?: ButtonSize; size?: ButtonSize
isLoading?: boolean; isLoading?: boolean
leftIcon?: React.ReactNode; leftIcon?: React.ReactNode
rightIcon?: React.ReactNode; rightIcon?: React.ReactNode
fullWidth?: boolean; fullWidth?: boolean
} }
/** /**
@@ -32,7 +32,7 @@ const Button: FC<ButtonProps> = ({
small: 'btn-sm', small: 'btn-sm',
medium: 'btn-md', medium: 'btn-md',
large: 'btn-lg', large: 'btn-lg',
}[size]; }[size]
// Variant class mapping // Variant class mapping
const variantClass = { const variantClass = {
@@ -41,7 +41,7 @@ const Button: FC<ButtonProps> = ({
danger: 'btn-danger', danger: 'btn-danger',
success: 'btn-success', success: 'btn-success',
warning: 'btn-warning', warning: 'btn-warning',
}[variant]; }[variant]
return ( return (
<button <button
@@ -56,12 +56,12 @@ const Button: FC<ButtonProps> = ({
<span className="spinner"></span> <span className="spinner"></span>
</span> </span>
)} )}
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>} {leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
<span className="btn-text">{children}</span> <span className="btn-text">{children}</span>
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>} {rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
</button> </button>
); )
}; }
export default Button; export default Button

View File

@@ -1,8 +1,8 @@
// Export all button components // Export all button components
export { default as Button } from './Button'; export { default as Button } from './Button'
export { default as ActionButton } from './ActionButton'; export { default as ActionButton } from './ActionButton'
export { default as AnimatedCheckbox } from './AnimatedCheckbox'; export { default as AnimatedCheckbox } from './AnimatedCheckbox'
// Export types // Export types
export type { ButtonVariant, ButtonSize } from './Button'; export type { ButtonVariant, ButtonSize } from './Button'
export type { ActionType } from './ActionButton'; export type { ActionType } from './ActionButton'

View File

@@ -4,11 +4,11 @@ export type LoadingType = 'spinner' | 'dots' | 'progress'
export type LoadingSize = 'small' | 'medium' | 'large' export type LoadingSize = 'small' | 'medium' | 'large'
interface LoadingIndicatorProps { interface LoadingIndicatorProps {
size?: LoadingSize; size?: LoadingSize
type?: LoadingType; type?: LoadingType
message?: string; message?: string
progress?: number; progress?: number
className?: string; className?: string
} }
/** /**
@@ -34,7 +34,7 @@ const LoadingIndicator = ({
switch (type) { switch (type) {
case 'spinner': case 'spinner':
return <div className="loading-spinner"></div> return <div className="loading-spinner"></div>
case 'dots': case 'dots':
return ( return (
<div className="loading-dots"> <div className="loading-dots">
@@ -43,7 +43,7 @@ const LoadingIndicator = ({
<div className="dot dot-3"></div> <div className="dot dot-3"></div>
</div> </div>
) )
case 'progress': case 'progress':
return ( return (
<div className="loading-progress"> <div className="loading-progress">
@@ -53,12 +53,10 @@ const LoadingIndicator = ({
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }} style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
></div> ></div>
</div> </div>
{progress > 0 && ( {progress > 0 && <div className="progress-percentage">{Math.round(progress)}%</div>}
<div className="progress-percentage">{Math.round(progress)}%</div>
)}
</div> </div>
) )
default: default:
return <div className="loading-spinner"></div> return <div className="loading-spinner"></div>
} }
@@ -72,4 +70,4 @@ const LoadingIndicator = ({
) )
} }
export default LoadingIndicator export default LoadingIndicator

View File

@@ -1,3 +1,3 @@
export { default as LoadingIndicator } from './LoadingIndicator'; export { default as LoadingIndicator } from './LoadingIndicator'
export type { LoadingSize, LoadingType } from './LoadingIndicator'; export type { LoadingSize, LoadingType } from './LoadingIndicator'

View File

@@ -1,13 +1,13 @@
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
export interface DialogProps { export interface DialogProps {
visible: boolean; visible: boolean
onClose?: () => void; onClose?: () => void
className?: string; className?: string
preventBackdropClose?: boolean; preventBackdropClose?: boolean
children: ReactNode; children: ReactNode
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large'
showAnimationOnUnmount?: boolean; showAnimationOnUnmount?: boolean
} }
/** /**
@@ -66,17 +66,12 @@ const Dialog = ({
}[size] }[size]
return ( return (
<div <div className={`dialog-overlay ${showContent ? 'visible' : ''}`} onClick={handleBackdropClick}>
className={`dialog-overlay ${showContent ? 'visible' : ''}`} <div className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}>
onClick={handleBackdropClick}
>
<div
className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}
>
{children} {children}
</div> </div>
</div> </div>
) )
} }
export default Dialog export default Dialog

View File

@@ -1,31 +1,23 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
export interface DialogActionsProps { export interface DialogActionsProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end'
} }
/** /**
* Actions container for dialog footers * Actions container for dialog footers
* Provides consistent spacing and alignment for action buttons * Provides consistent spacing and alignment for action buttons
*/ */
const DialogActions = ({ const DialogActions = ({ children, className = '', align = 'end' }: DialogActionsProps) => {
children,
className = '',
align = 'end'
}: DialogActionsProps) => {
const alignClass = { const alignClass = {
start: 'justify-start', start: 'justify-start',
center: 'justify-center', center: 'justify-center',
end: 'justify-end' end: 'justify-end',
}[align]; }[align]
return ( return <div className={`dialog-actions ${alignClass} ${className}`}>{children}</div>
<div className={`dialog-actions ${alignClass} ${className}`}>
{children}
</div>
)
} }
export default DialogActions export default DialogActions

View File

@@ -1,8 +1,8 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
export interface DialogBodyProps { export interface DialogBodyProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
} }
/** /**
@@ -10,11 +10,7 @@ export interface DialogBodyProps {
* Contains the main content with scrolling capability * Contains the main content with scrolling capability
*/ */
const DialogBody = ({ children, className = '' }: DialogBodyProps) => { const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
return ( return <div className={`dialog-body ${className}`}>{children}</div>
<div className={`dialog-body ${className}`}>
{children}
</div>
)
} }
export default DialogBody export default DialogBody

View File

@@ -1,8 +1,8 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
export interface DialogFooterProps { export interface DialogFooterProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
} }
/** /**
@@ -10,11 +10,7 @@ export interface DialogFooterProps {
* Contains action buttons and optional status information * Contains action buttons and optional status information
*/ */
const DialogFooter = ({ children, className = '' }: DialogFooterProps) => { const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
return ( return <div className={`dialog-footer ${className}`}>{children}</div>
<div className={`dialog-footer ${className}`}>
{children}
</div>
)
} }
export default DialogFooter export default DialogFooter

View File

@@ -1,9 +1,9 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
export interface DialogHeaderProps { export interface DialogHeaderProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
onClose?: () => void; onClose?: () => void
} }
/** /**
@@ -15,11 +15,7 @@ const DialogHeader = ({ children, className = '', onClose }: DialogHeaderProps)
<div className={`dialog-header ${className}`}> <div className={`dialog-header ${className}`}>
{children} {children}
{onClose && ( {onClose && (
<button <button className="dialog-close-button" onClick={onClose} aria-label="Close dialog">
className="dialog-close-button"
onClick={onClose}
aria-label="Close dialog"
>
× ×
</button> </button>
)} )}
@@ -27,4 +23,4 @@ const DialogHeader = ({ children, className = '', onClose }: DialogHeaderProps)
) )
} }
export default DialogHeader export default DialogHeader

View File

@@ -8,15 +8,15 @@ import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types' import { DlcInfo } from '@/types'
export interface DlcSelectionDialogProps { export interface DlcSelectionDialogProps {
visible: boolean; visible: boolean
gameTitle: string; gameTitle: string
dlcs: DlcInfo[]; dlcs: DlcInfo[]
onClose: () => void; onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void; onConfirm: (selectedDlcs: DlcInfo[]) => void
isLoading: boolean; isLoading: boolean
isEditMode?: boolean; isEditMode?: boolean
loadingProgress?: number; loadingProgress?: number
estimatedTimeLeft?: string; estimatedTimeLeft?: string
} }
/** /**
@@ -56,18 +56,18 @@ const DlcSelectionDialog = ({
if (!initialized) { if (!initialized) {
// Create a new array to ensure we don't share references // Create a new array to ensure we don't share references
setSelectedDlcs([...dlcs]) setSelectedDlcs([...dlcs])
// Determine initial selectAll state based on if all DLCs are enabled // Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every((dlc) => dlc.enabled) const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected) setSelectAll(allSelected)
// Mark as initialized to avoid resetting selections on subsequent updates // Mark as initialized to avoid resetting selections on subsequent updates
setInitialized(true) setInitialized(true)
} else { } else {
// Find new DLCs that aren't in our current selection // Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid)) const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid)) const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// If we found new DLCs, add them to our selection // If we found new DLCs, add them to our selection
if (newDlcs.length > 0) { if (newDlcs.length > 0) {
setSelectedDlcs((prev) => [...prev, ...newDlcs]) setSelectedDlcs((prev) => [...prev, ...newDlcs])
@@ -118,9 +118,9 @@ const DlcSelectionDialog = ({
// Submit selected DLCs to parent component // Submit selected DLCs to parent component
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
// Create a deep copy to prevent reference issues // Create a deep copy to prevent reference issues
const dlcsCopy = JSON.parse(JSON.stringify(selectedDlcs)); const dlcsCopy = JSON.parse(JSON.stringify(selectedDlcs))
onConfirm(dlcsCopy); onConfirm(dlcsCopy)
}, [onConfirm, selectedDlcs]); }, [onConfirm, selectedDlcs])
// Count selected DLCs // Count selected DLCs
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
@@ -128,7 +128,7 @@ const DlcSelectionDialog = ({
// Format dialog title and messages based on mode // Format dialog title and messages based on mode
const dialogTitle = isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable' const dialogTitle = isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'
const actionButtonText = isEditMode ? 'Save Changes' : 'Install with Selected DLCs' const actionButtonText = isEditMode ? 'Save Changes' : 'Install with Selected DLCs'
// Format loading message to show total number of DLCs found // Format loading message to show total number of DLCs found
const getLoadingInfoText = () => { const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) { if (isLoading && loadingProgress < 100) {
@@ -140,12 +140,7 @@ const DlcSelectionDialog = ({
} }
return ( return (
<Dialog <Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
visible={visible}
onClose={onClose}
size="large"
preventBackdropClose={isLoading}
>
<DialogHeader onClose={onClose}> <DialogHeader onClose={onClose}>
<h3>{dialogTitle}</h3> <h3>{dialogTitle}</h3>
<div className="dlc-game-info"> <div className="dlc-game-info">
@@ -224,11 +219,7 @@ const DlcSelectionDialog = ({
> >
Cancel Cancel
</Button> </Button>
<Button <Button variant="primary" onClick={handleConfirm} disabled={isLoading}>
variant="primary"
onClick={handleConfirm}
disabled={isLoading}
>
{actionButtonText} {actionButtonText}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -237,4 +228,4 @@ const DlcSelectionDialog = ({
) )
} }
export default DlcSelectionDialog export default DlcSelectionDialog

View File

@@ -7,20 +7,20 @@ import DialogActions from './DialogActions'
import { Button } from '@/components/buttons' import { Button } from '@/components/buttons'
export interface InstallationInstructions { export interface InstallationInstructions {
type: string; type: string
command: string; command: string
game_title: string; game_title: string
dlc_count?: number; dlc_count?: number
} }
export interface ProgressDialogProps { export interface ProgressDialogProps {
visible: boolean; visible: boolean
title: string; title: string
message: string; message: string
progress: number; progress: number
showInstructions?: boolean; showInstructions?: boolean
instructions?: InstallationInstructions; instructions?: InstallationInstructions
onClose?: () => void; onClose?: () => void
} }
/** /**
@@ -126,7 +126,7 @@ const ProgressDialog = ({
<DialogHeader> <DialogHeader>
<h3>{title}</h3> <h3>{title}</h3>
</DialogHeader> </DialogHeader>
<DialogBody> <DialogBody>
<p>{message}</p> <p>{message}</p>
@@ -150,24 +150,17 @@ const ProgressDialog = ({
</div> </div>
)} )}
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<DialogActions> <DialogActions>
{showInstructions && showCopyButton && ( {showInstructions && showCopyButton && (
<Button <Button variant="primary" onClick={handleCopyCommand}>
variant="primary"
onClick={handleCopyCommand}
>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'} {copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</Button> </Button>
)} )}
{isCloseButtonEnabled && ( {isCloseButtonEnabled && (
<Button <Button variant="secondary" onClick={onClose} disabled={!isCloseButtonEnabled}>
variant="secondary"
onClick={onClose}
disabled={!isCloseButtonEnabled}
>
Close Close
</Button> </Button>
)} )}
@@ -177,4 +170,4 @@ const ProgressDialog = ({
) )
} }
export default ProgressDialog export default ProgressDialog

View File

@@ -1,17 +1,17 @@
// Export all dialog components // Export all dialog components
export { default as Dialog } from './Dialog'; export { default as Dialog } from './Dialog'
export { default as DialogHeader } from './DialogHeader'; export { default as DialogHeader } from './DialogHeader'
export { default as DialogBody } from './DialogBody'; export { default as DialogBody } from './DialogBody'
export { default as DialogFooter } from './DialogFooter'; export { default as DialogFooter } from './DialogFooter'
export { default as DialogActions } from './DialogActions'; export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog'; export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog'; export { default as DlcSelectionDialog } from './DlcSelectionDialog'
// Export types // Export types
export type { DialogProps } from './Dialog'; export type { DialogProps } from './Dialog'
export type { DialogHeaderProps } from './DialogHeader'; export type { DialogHeaderProps } from './DialogHeader'
export type { DialogBodyProps } from './DialogBody'; export type { DialogBodyProps } from './DialogBody'
export type { DialogFooterProps } from './DialogFooter'; export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions'; export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'; export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'; export type { DlcSelectionDialogProps } from './DlcSelectionDialog'

View File

@@ -161,4 +161,4 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
) )
} }
export default GameItem export default GameItem

View File

@@ -1,5 +1,5 @@
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 } from '@/components/buttons'
import { Game } from '@/types' import { Game } from '@/types'
import LoadingIndicator from '../common/LoadingIndicator' import LoadingIndicator from '../common/LoadingIndicator'
@@ -18,7 +18,7 @@ interface GameListProps {
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => { const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false) const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title // Sort games alphabetically by title
const sortedGames = useMemo(() => { const sortedGames = useMemo(() => {
return [...games].sort((a, b) => a.title.localeCompare(b.title)) return [...games].sort((a, b) => a.title.localeCompare(b.title))
}, [games]) }, [games])
@@ -35,11 +35,7 @@ const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="game-list"> <div className="game-list">
<LoadingIndicator <LoadingIndicator type="spinner" size="large" message="Scanning for games..." />
type="spinner"
size="large"
message="Scanning for games..."
/>
</div> </div>
) )
} }
@@ -68,4 +64,4 @@ const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
) )
} }
export default GameList export default GameList

View File

@@ -16,24 +16,24 @@ const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
try { try {
// Only preload the first batch for performance (10 images max) // Only preload the first batch for performance (10 images max)
const batchToPreload = gameIds.slice(0, 10) const batchToPreload = gameIds.slice(0, 10)
// Track loading progress // Track loading progress
let loadedCount = 0 let loadedCount = 0
const totalImages = batchToPreload.length const totalImages = batchToPreload.length
// Load images in parallel // Load images in parallel
await Promise.allSettled( await Promise.allSettled(
batchToPreload.map(async (id) => { batchToPreload.map(async (id) => {
await findBestGameImage(id) await findBestGameImage(id)
loadedCount++ loadedCount++
// If all images are loaded, call onComplete // If all images are loaded, call onComplete
if (loadedCount === totalImages && onComplete) { if (loadedCount === totalImages && onComplete) {
onComplete() onComplete()
} }
}) })
) )
// Fallback if Promise.allSettled doesn't trigger onComplete // Fallback if Promise.allSettled doesn't trigger onComplete
if (onComplete) { if (onComplete) {
onComplete() onComplete()
@@ -58,4 +58,4 @@ const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
return null return null
} }
export default ImagePreloader export default ImagePreloader

View File

@@ -1,4 +1,4 @@
// Export all game components // Export all game components
export { default as GameList } from './GameList'; export { default as GameList } from './GameList'
export { default as GameItem } from './GameItem'; export { default as GameItem } from './GameItem'
export { default as ImagePreloader } from './ImagePreloader'; export { default as ImagePreloader } from './ImagePreloader'

View File

@@ -45,7 +45,7 @@ const getSizeValue = (size: IconSize): string => {
sm: '16px', sm: '16px',
md: '24px', md: '24px',
lg: '32px', lg: '32px',
xl: '48px' xl: '48px',
} }
return sizeMap[size] || sizeMap.md return sizeMap[size] || sizeMap.md
@@ -54,11 +54,15 @@ const getSizeValue = (size: IconSize): string => {
/** /**
* Gets the icon component based on name and variant * Gets the icon component based on name and variant
*/ */
const getIconComponent = (name: string, variant: IconVariant | string): React.ComponentType<React.SVGProps<SVGSVGElement>> | null => { const getIconComponent = (
name: string,
variant: IconVariant | string
): React.ComponentType<React.SVGProps<SVGSVGElement>> | null => {
// Normalize variant to ensure it's a valid IconVariant // Normalize variant to ensure it's a valid IconVariant
const normalizedVariant = (variant === 'bold' || variant === 'outline' || variant === 'brand') const normalizedVariant =
? variant as IconVariant variant === 'bold' || variant === 'outline' || variant === 'brand'
: undefined; ? (variant as IconVariant)
: undefined
// Try to get the icon from the specified variant // Try to get the icon from the specified variant
switch (normalizedVariant) { switch (normalizedVariant) {
@@ -97,7 +101,7 @@ const Icon: React.FC<IconProps> = ({
}) => { }) => {
// Determine default variant based on icon type if no variant provided // Determine default variant based on icon type if no variant provided
let defaultVariant: IconVariant | string = variant let defaultVariant: IconVariant | string = variant
if (defaultVariant === undefined) { if (defaultVariant === undefined) {
if (BRAND_ICON_NAMES.has(name)) { if (BRAND_ICON_NAMES.has(name)) {
defaultVariant = 'brand' defaultVariant = 'brand'
@@ -105,17 +109,17 @@ const Icon: React.FC<IconProps> = ({
defaultVariant = 'bold' // Default to bold for non-brand icons defaultVariant = 'bold' // Default to bold for non-brand icons
} }
} }
// Get the icon component based on name and variant // Get the icon component based on name and variant
let finalIconComponent = getIconComponent(name, defaultVariant) let finalIconComponent = getIconComponent(name, defaultVariant)
let finalVariant = defaultVariant let finalVariant = defaultVariant
// Try fallbacks if the icon doesn't exist in the requested variant // Try fallbacks if the icon doesn't exist in the requested variant
if (!finalIconComponent && defaultVariant !== 'outline') { if (!finalIconComponent && defaultVariant !== 'outline') {
finalIconComponent = getIconComponent(name, 'outline') finalIconComponent = getIconComponent(name, 'outline')
finalVariant = 'outline' finalVariant = 'outline'
} }
if (!finalIconComponent && defaultVariant !== 'bold') { if (!finalIconComponent && defaultVariant !== 'bold') {
finalIconComponent = getIconComponent(name, 'bold') finalIconComponent = getIconComponent(name, 'bold')
finalVariant = 'bold' finalVariant = 'bold'
@@ -125,7 +129,7 @@ const Icon: React.FC<IconProps> = ({
finalIconComponent = getIconComponent(name, 'brand') finalIconComponent = getIconComponent(name, 'brand')
finalVariant = 'brand' finalVariant = 'brand'
} }
// If still no icon found, return null // If still no icon found, return null
if (!finalIconComponent) { if (!finalIconComponent) {
console.warn(`Icon not found: ${name} (${defaultVariant})`) console.warn(`Icon not found: ${name} (${defaultVariant})`)
@@ -134,7 +138,7 @@ const Icon: React.FC<IconProps> = ({
const sizeValue = getSizeValue(size) const sizeValue = getSizeValue(size)
const combinedClassName = `icon icon-${size} icon-${finalVariant} ${className}`.trim() const combinedClassName = `icon icon-${size} icon-${finalVariant} ${className}`.trim()
const IconComponentToRender = finalIconComponent const IconComponentToRender = finalIconComponent
return ( return (
@@ -142,7 +146,7 @@ const Icon: React.FC<IconProps> = ({
className={combinedClassName} className={combinedClassName}
width={sizeValue} width={sizeValue}
height={sizeValue} height={sizeValue}
fill={fillColor || 'currentColor'} fill={fillColor || 'currentColor'}
stroke={strokeColor || 'currentColor'} stroke={strokeColor || 'currentColor'}
role="img" role="img"
aria-hidden={!title} aria-hidden={!title}
@@ -152,4 +156,4 @@ const Icon: React.FC<IconProps> = ({
) )
} }
export default Icon export default Icon

View File

@@ -21,4 +21,4 @@
// IconComponent.displayName = `${name}Icon` // IconComponent.displayName = `${name}Icon`
// return IconComponent // return IconComponent
//} //}
// //

View File

@@ -4,4 +4,4 @@ export { ReactComponent as Steam } from './steam.svg'
export { ReactComponent as Windows } from './windows.svg' export { ReactComponent as Windows } from './windows.svg'
export { ReactComponent as Github } from './github.svg' export { ReactComponent as Github } from './github.svg'
export { ReactComponent as Discord } from './discord.svg' export { ReactComponent as Discord } from './discord.svg'
export { ReactComponent as Proton } from './proton.svg' export { ReactComponent as Proton } from './proton.svg'

View File

@@ -57,7 +57,7 @@ export const IconNames = {
Warning: warning, Warning: warning,
Wine: wine, Wine: wine,
Diamond: diamond, Diamond: diamond,
// Brand icons // Brand icons
Discord: discord, Discord: discord,
GitHub: github, GitHub: github,
@@ -97,4 +97,4 @@ export const IconNames = {
//export const CheckBoldIcon = createIconComponent(check, 'bold') //export const CheckBoldIcon = createIconComponent(check, 'bold')
//export const InfoBoldIcon = createIconComponent(info, 'bold') //export const InfoBoldIcon = createIconComponent(info, 'bold')
//export const WarningBoldIcon = createIconComponent(warning, 'bold') //export const WarningBoldIcon = createIconComponent(warning, 'bold')
//export const ErrorBoldIcon = createIconComponent(error, 'bold') //export const ErrorBoldIcon = createIconComponent(error, 'bold')

View File

@@ -15,4 +15,4 @@ export { ReactComponent as Search } from './search.svg'
export { ReactComponent as Trash } from './trash.svg' export { ReactComponent as Trash } from './trash.svg'
export { ReactComponent as Warning } from './warning.svg' export { ReactComponent as Warning } from './warning.svg'
export { ReactComponent as Wine } from './wine.svg' export { ReactComponent as Wine } from './wine.svg'
export { ReactComponent as Diamond } from './diamond.svg' export { ReactComponent as Diamond } from './diamond.svg'

View File

@@ -15,4 +15,4 @@ export { ReactComponent as Search } from './search.svg'
export { ReactComponent as Trash } from './trash.svg' export { ReactComponent as Trash } from './trash.svg'
export { ReactComponent as Warning } from './warning.svg' export { ReactComponent as Warning } from './warning.svg'
export { ReactComponent as Wine } from './wine.svg' export { ReactComponent as Wine } from './wine.svg'
export { ReactComponent as Diamond } from './diamond.svg' export { ReactComponent as Diamond } from './diamond.svg'

View File

@@ -109,13 +109,7 @@ const AnimatedBackground = () => {
} }
}, []) }, [])
return ( return <canvas ref={canvasRef} className="animated-background" aria-hidden="true" />
<canvas
ref={canvasRef}
className="animated-background"
aria-hidden="true"
/>
)
} }
export default AnimatedBackground export default AnimatedBackground

View File

@@ -2,14 +2,14 @@ import { Component, ErrorInfo, ReactNode } from 'react'
import { Button } from '@/components/buttons' import { Button } from '@/components/buttons'
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode
fallback?: ReactNode; fallback?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void; onError?: (error: Error, errorInfo: ErrorInfo) => void
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean; hasError: boolean
error: Error | null; error: Error | null
} }
/** /**
@@ -35,7 +35,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log the error // Log the error
console.error('ErrorBoundary caught an error:', error, errorInfo) console.error('ErrorBoundary caught an error:', error, errorInfo)
// Call the onError callback if provided // Call the onError callback if provided
if (this.props.onError) { if (this.props.onError) {
this.props.onError(error, errorInfo) this.props.onError(error, errorInfo)
@@ -52,22 +52,18 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
if (this.props.fallback) { if (this.props.fallback) {
return this.props.fallback return this.props.fallback
} }
// Default error UI // Default error UI
return ( return (
<div className="error-container"> <div className="error-container">
<h2>Something went wrong</h2> <h2>Something went wrong</h2>
<details> <details>
<summary>Error details</summary> <summary>Error details</summary>
<p>{this.state.error?.toString()}</p> <p>{this.state.error?.toString()}</p>
</details> </details>
<Button <Button variant="primary" onClick={this.handleReset} className="error-retry-button">
variant="primary"
onClick={this.handleReset}
className="error-retry-button"
>
Try again Try again
</Button> </Button>
</div> </div>
@@ -78,4 +74,4 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
} }
} }
export default ErrorBoundary export default ErrorBoundary

View File

@@ -12,12 +12,7 @@ interface HeaderProps {
* Application header component * Application header component
* Contains the app title, search input, and refresh button * Contains the app title, search input, and refresh button
*/ */
const Header = ({ const Header = ({ onRefresh, refreshDisabled = false, onSearch, searchQuery }: HeaderProps) => {
onRefresh,
refreshDisabled = false,
onSearch,
searchQuery,
}: HeaderProps) => {
return ( return (
<header className="app-header"> <header className="app-header">
<div className="app-title"> <div className="app-title">
@@ -25,9 +20,9 @@ const Header = ({
<h1>CreamLinux</h1> <h1>CreamLinux</h1>
</div> </div>
<div className="header-controls"> <div className="header-controls">
<Button <Button
variant="primary" variant="primary"
onClick={onRefresh} onClick={onRefresh}
disabled={refreshDisabled} disabled={refreshDisabled}
className="refresh-button" className="refresh-button"
leftIcon={<Icon name={refresh} variant="bold" size="md" />} leftIcon={<Icon name={refresh} variant="bold" size="md" />}
@@ -49,4 +44,4 @@ const Header = ({
) )
} }
export default Header export default Header

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
interface InitialLoadingScreenProps { interface InitialLoadingScreenProps {
message: string; message: string
progress: number; progress: number
onComplete?: () => void; onComplete?: () => void
} }
/** /**
@@ -11,36 +11,36 @@ interface InitialLoadingScreenProps {
*/ */
const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps) => { const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps) => {
const [detailedStatus, setDetailedStatus] = useState<string[]>([ const [detailedStatus, setDetailedStatus] = useState<string[]>([
"Initializing application...", 'Initializing application...',
"Setting up Steam integration...", 'Setting up Steam integration...',
"Preparing DLC management..." 'Preparing DLC management...',
]); ])
// Use a sequence of messages based on progress // Use a sequence of messages based on progress
useEffect(() => { useEffect(() => {
const messages = [ const messages = [
{ threshold: 10, message: "Checking system requirements..." }, { threshold: 10, message: 'Checking system requirements...' },
{ threshold: 30, message: "Scanning Steam libraries..." }, { threshold: 30, message: 'Scanning Steam libraries...' },
{ threshold: 50, message: "Discovering games..." }, { threshold: 50, message: 'Discovering games...' },
{ threshold: 70, message: "Analyzing game configurations..." }, { threshold: 70, message: 'Analyzing game configurations...' },
{ threshold: 90, message: "Preparing user interface..." }, { threshold: 90, message: 'Preparing user interface...' },
{ threshold: 100, message: "Ready to launch!" } { threshold: 100, message: 'Ready to launch!' },
]; ]
// Find current status message based on progress // Find current status message based on progress
const currentMessage = messages.find(m => progress <= m.threshold)?.message || "Loading..."; const currentMessage = messages.find((m) => progress <= m.threshold)?.message || 'Loading...'
// Add new messages to the log as progress increases // Add new messages to the log as progress increases
if (currentMessage && !detailedStatus.includes(currentMessage)) { if (currentMessage && !detailedStatus.includes(currentMessage)) {
setDetailedStatus(prev => [...prev, currentMessage]); setDetailedStatus((prev) => [...prev, currentMessage])
} }
}, [progress, detailedStatus]); }, [progress, detailedStatus])
return ( return (
<div className="initial-loading-screen"> <div className="initial-loading-screen">
<div className="loading-content"> <div className="loading-content">
<h1>CreamLinux</h1> <h1>CreamLinux</h1>
<div className="loading-animation"> <div className="loading-animation">
{/* Enhanced animation with SVG or more elaborate CSS animation */} {/* Enhanced animation with SVG or more elaborate CSS animation */}
<div className="loading-circles"> <div className="loading-circles">
@@ -49,9 +49,9 @@ const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps)
<div className="circle circle-3"></div> <div className="circle circle-3"></div>
</div> </div>
</div> </div>
<p className="loading-message">{message}</p> <p className="loading-message">{message}</p>
{/* Add a detailed status log that shows progress steps */} {/* Add a detailed status log that shows progress steps */}
<div className="loading-status-log"> <div className="loading-status-log">
{detailedStatus.slice(-4).map((status, index) => ( {detailedStatus.slice(-4).map((status, index) => (
@@ -61,15 +61,15 @@ const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps)
</div> </div>
))} ))}
</div> </div>
<div className="progress-bar-container"> <div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress}%` }} /> <div className="progress-bar" style={{ width: `${progress}%` }} />
</div> </div>
<div className="progress-percentage">{Math.round(progress)}%</div> <div className="progress-percentage">{Math.round(progress)}%</div>
</div> </div>
</div> </div>
); )
}; }
export default InitialLoadingScreen export default InitialLoadingScreen

View File

@@ -22,29 +22,24 @@ const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
const filters: FilterItem[] = [ const filters: FilterItem[] = [
{ id: 'all', label: 'All Games', icon: layers, variant: 'bold' }, { id: 'all', label: 'All Games', icon: layers, variant: 'bold' },
{ id: 'native', label: 'Native', icon: linux, variant: 'brand' }, { id: 'native', label: 'Native', icon: linux, variant: 'brand' },
{ id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' } { id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' },
] ]
return ( return (
<div className="sidebar"> <div className="sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
<h2>Library</h2> <h2>Library</h2>
</div> </div>
<ul className="filter-list"> <ul className="filter-list">
{filters.map(filter => ( {filters.map((filter) => (
<li <li
key={filter.id} key={filter.id}
className={currentFilter === filter.id ? 'active' : ''} className={currentFilter === filter.id ? 'active' : ''}
onClick={() => setFilter(filter.id)} onClick={() => setFilter(filter.id)}
> >
<div className="filter-item"> <div className="filter-item">
<Icon <Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
name={filter.icon}
variant={filter.variant}
size="md"
className="filter-icon"
/>
<span>{filter.label}</span> <span>{filter.label}</span>
</div> </div>
</li> </li>
@@ -54,4 +49,4 @@ const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
) )
} }
export default Sidebar export default Sidebar

View File

@@ -1,6 +1,6 @@
// Export all layout components // Export all layout components
export { default as Header } from './Header'; export { default as Header } from './Header'
export { default as Sidebar } from './Sidebar'; export { default as Sidebar } from './Sidebar'
export { default as AnimatedBackground } from './AnimatedBackground'; export { default as AnimatedBackground } from './AnimatedBackground'
export { default as InitialLoadingScreen } from './InitialLoadingScreen'; export { default as InitialLoadingScreen } from './InitialLoadingScreen'
export { default as ErrorBoundary } from './ErrorBoundary'; export { default as ErrorBoundary } from './ErrorBoundary'

View File

@@ -2,38 +2,38 @@ import { ReactNode, useState, useEffect, useCallback } from 'react'
import { Icon, check, info, warning, error } from '@/components/icons' import { Icon, check, info, warning, error } from '@/components/icons'
export interface ToastProps { export interface ToastProps {
id: string; id: string
type: 'success' | 'error' | 'warning' | 'info'; type: 'success' | 'error' | 'warning' | 'info'
title?: string; title?: string
message: string; message: string
duration?: number; duration?: number
onDismiss: (id: string) => void; onDismiss: (id: string) => void
} }
/** /**
* Individual Toast component * Individual Toast component
* Displays a notification message with automatic dismissal * Displays a notification message with automatic dismissal
*/ */
const Toast = ({ const Toast = ({
id, id,
type, type,
title, title,
message, message,
duration = 5000, // default 5 seconds duration = 5000, // default 5 seconds
onDismiss onDismiss,
}: ToastProps) => { }: ToastProps) => {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false)
// Use useCallback to memoize the handleDismiss function // Use useCallback to memoize the handleDismiss function
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setIsClosing(true); setIsClosing(true)
// Give time for exit animation // Give time for exit animation
setTimeout(() => { setTimeout(() => {
setVisible(false); setVisible(false)
setTimeout(() => onDismiss(id), 50); setTimeout(() => onDismiss(id), 50)
}, 300); }, 300)
}, [id, onDismiss]); }, [id, onDismiss])
// Handle animation on mount/unmount // Handle animation on mount/unmount
useEffect(() => { useEffect(() => {
@@ -60,20 +60,22 @@ const Toast = ({
const getIcon = (): ReactNode => { const getIcon = (): ReactNode => {
switch (type) { switch (type) {
case 'success': case 'success':
return <Icon name={check} size="md" variant='bold' /> return <Icon name={check} size="md" variant="bold" />
case 'error': case 'error':
return <Icon name={error} size="md" variant='bold' /> return <Icon name={error} size="md" variant="bold" />
case 'warning': case 'warning':
return <Icon name={warning} size="md" variant='bold' /> return <Icon name={warning} size="md" variant="bold" />
case 'info': case 'info':
return <Icon name={info} size="md" variant='bold' /> return <Icon name={info} size="md" variant="bold" />
default: default:
return <Icon name={info} size="md" variant='bold' /> return <Icon name={info} size="md" variant="bold" />
} }
} }
return ( return (
<div className={`toast toast-${type} ${visible ? 'visible' : ''} ${isClosing ? 'closing' : ''}`}> <div
className={`toast toast-${type} ${visible ? 'visible' : ''} ${isClosing ? 'closing' : ''}`}
>
<div className="toast-icon">{getIcon()}</div> <div className="toast-icon">{getIcon()}</div>
<div className="toast-content"> <div className="toast-content">
{title && <h4 className="toast-title">{title}</h4>} {title && <h4 className="toast-title">{title}</h4>}
@@ -86,4 +88,4 @@ const Toast = ({
) )
} }
export default Toast export default Toast

View File

@@ -1,32 +1,28 @@
import Toast, { ToastProps } from './Toast' import Toast, { ToastProps } from './Toast'
export type ToastPosition = export type ToastPosition =
| 'top-right' | 'top-right'
| 'top-left' | 'top-left'
| 'bottom-right' | 'bottom-right'
| 'bottom-left' | 'bottom-left'
| 'top-center' | 'top-center'
| 'bottom-center' | 'bottom-center'
interface ToastContainerProps { interface ToastContainerProps {
toasts: Omit<ToastProps, 'onDismiss'>[]; toasts: Omit<ToastProps, 'onDismiss'>[]
onDismiss: (id: string) => void; onDismiss: (id: string) => void
position?: ToastPosition; position?: ToastPosition
} }
/** /**
* Container for toast notifications * Container for toast notifications
* Manages positioning and rendering of all toast notifications * Manages positioning and rendering of all toast notifications
*/ */
const ToastContainer = ({ const ToastContainer = ({ toasts, onDismiss, position = 'bottom-right' }: ToastContainerProps) => {
toasts,
onDismiss,
position = 'bottom-right',
}: ToastContainerProps) => {
if (toasts.length === 0) { if (toasts.length === 0) {
return null return null
} }
return ( return (
<div className={`toast-container ${position}`}> <div className={`toast-container ${position}`}>
{toasts.map((toast) => ( {toasts.map((toast) => (
@@ -44,4 +40,4 @@ const ToastContainer = ({
) )
} }
export default ToastContainer export default ToastContainer

View File

@@ -1,5 +1,5 @@
export { default as Toast } from './Toast'; export { default as Toast } from './Toast'
export { default as ToastContainer } from './ToastContainer'; export { default as ToastContainer } from './ToastContainer'
export type { ToastProps } from './Toast'; export type { ToastProps } from './Toast'
export type { ToastPosition } from './ToastContainer'; export type { ToastPosition } from './ToastContainer'

View File

@@ -4,54 +4,58 @@ import { ActionType } from '@/components/buttons/ActionButton'
// Types for context sub-components // Types for context sub-components
export interface InstallationInstructions { export interface InstallationInstructions {
type: string; type: string
command: string; command: string
game_title: string; game_title: string
dlc_count?: number; dlc_count?: number
} }
export interface DlcDialogState { export interface DlcDialogState {
visible: boolean; visible: boolean
gameId: string; gameId: string
gameTitle: string; gameTitle: string
dlcs: DlcInfo[]; dlcs: DlcInfo[]
isLoading: boolean; isLoading: boolean
isEditMode: boolean; isEditMode: boolean
progress: number; progress: number
timeLeft?: string; timeLeft?: string
} }
export interface ProgressDialogState { export interface ProgressDialogState {
visible: boolean; visible: boolean
title: string; title: string
message: string; message: string
progress: number; progress: number
showInstructions: boolean; showInstructions: boolean
instructions?: InstallationInstructions; instructions?: InstallationInstructions
} }
// Define the context type // Define the context type
export interface AppContextType { export interface AppContextType {
// Game state // Game state
games: Game[]; games: Game[]
isLoading: boolean; isLoading: boolean
error: string | null; error: string | null
loadGames: () => Promise<boolean>; loadGames: () => Promise<boolean>
handleProgressDialogClose: () => void; handleProgressDialogClose: () => void
// DLC management // DLC management
dlcDialog: DlcDialogState; dlcDialog: DlcDialogState
handleGameEdit: (gameId: string) => void; handleGameEdit: (gameId: string) => void
handleDlcDialogClose: () => void; handleDlcDialogClose: () => void
// Game actions // Game actions
progressDialog: ProgressDialogState; progressDialog: ProgressDialogState
handleGameAction: (gameId: string, action: ActionType) => Promise<void>; handleGameAction: (gameId: string, action: ActionType) => Promise<void>
handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void; handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void
// Toast notifications // Toast notifications
showToast: (message: string, type: 'success' | 'error' | 'warning' | 'info', options?: Record<string, unknown>) => void; showToast: (
message: string,
type: 'success' | 'error' | 'warning' | 'info',
options?: Record<string, unknown>
) => void
} }
// Create the context with a default value // Create the context with a default value
export const AppContext = createContext<AppContextType | undefined>(undefined); export const AppContext = createContext<AppContextType | undefined>(undefined)

View File

@@ -7,7 +7,7 @@ import { ToastContainer } from '@/components/notifications'
// Context provider component // Context provider component
interface AppProviderProps { interface AppProviderProps {
children: ReactNode; children: ReactNode
} }
/** /**
@@ -16,13 +16,7 @@ interface AppProviderProps {
*/ */
export const AppProvider = ({ children }: AppProviderProps) => { export const AppProvider = ({ children }: AppProviderProps) => {
// Use our custom hooks // Use our custom hooks
const { const { games, isLoading, error, loadGames, setGames } = useGames()
games,
isLoading,
error,
loadGames,
setGames,
} = useGames()
const { const {
dlcDialog, dlcDialog,
@@ -38,24 +32,17 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleGameAction: executeGameAction, handleGameAction: executeGameAction,
handleDlcConfirm: executeDlcConfirm, handleDlcConfirm: executeDlcConfirm,
} = useGameActions() } = useGameActions()
const { const { toasts, removeToast, success, error: showError, warning, info } = useToasts()
toasts,
removeToast,
success,
error: showError,
warning,
info
} = useToasts()
// Game action handler with proper error reporting // Game action handler with proper error reporting
const handleGameAction = async (gameId: string, action: ActionType) => { const handleGameAction = async (gameId: string, action: ActionType) => {
const game = games.find(g => g.id === gameId) const game = games.find((g) => g.id === gameId)
if (!game) { if (!game) {
showError("Game not found") showError('Game not found')
return return
} }
// For DLC installation, we want to show the DLC selection dialog first // For DLC installation, we want to show the DLC selection dialog first
if (action === 'install_cream') { if (action === 'install_cream') {
try { try {
@@ -70,7 +57,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
isEditMode: false, // This is a new installation isEditMode: false, // This is a new installation
progress: 0, progress: 0,
}) })
// Start streaming DLCs // Start streaming DLCs
streamGameDlcs(gameId) streamGameDlcs(gameId)
return return
@@ -79,76 +66,96 @@ export const AppProvider = ({ children }: AppProviderProps) => {
return return
} }
} }
// For other actions (uninstall cream, install/uninstall smoke) // For other actions (uninstall cream, install/uninstall smoke)
// Mark game as installing // Mark game as installing
setGames(prevGames => setGames((prevGames) =>
prevGames.map(g => g.id === gameId ? {...g, installing: true} : g) prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
) )
try { try {
await executeGameAction(gameId, action, games) await executeGameAction(gameId, action, games)
// Show success message // Show success message
if (action.includes('install')) { if (action.includes('install')) {
success(`Successfully installed ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} for ${game.title}`) success(
`Successfully installed ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} for ${game.title}`
)
} else { } else {
success(`Successfully uninstalled ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} from ${game.title}`) success(
`Successfully uninstalled ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} from ${game.title}`
)
} }
} catch (error) { } catch (error) {
showError(`Action failed: ${error}`) showError(`Action failed: ${error}`)
} finally { } finally {
// Reset installing state // Reset installing state
setGames(prevGames => setGames((prevGames) =>
prevGames.map(g => g.id === gameId ? {...g, installing: false} : g) prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
) )
} }
} }
// DLC confirmation wrapper // DLC confirmation wrapper
const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => { const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => {
const { gameId, isEditMode } = dlcDialog const { gameId, isEditMode } = dlcDialog
// MODIFIED: Create a deep copy to ensure we don't have reference issues // MODIFIED: Create a deep copy to ensure we don't have reference issues
const dlcsCopy = selectedDlcs.map(dlc => ({...dlc})) const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc }))
// Log detailed info before closing dialog // Log detailed info before closing dialog
console.log(`Saving ${dlcsCopy.filter(d => d.enabled).length} enabled and ${ console.log(
dlcsCopy.length - dlcsCopy.filter(d => d.enabled).length `Saving ${dlcsCopy.filter((d) => d.enabled).length} enabled and ${
} disabled DLCs`) dlcsCopy.length - dlcsCopy.filter((d) => d.enabled).length
} disabled DLCs`
)
// Close dialog FIRST to avoid UI state issues // Close dialog FIRST to avoid UI state issues
closeDlcDialog() closeDlcDialog()
// Update game state to show it's installing // Update game state to show it's installing
setGames(prevGames => setGames((prevGames) =>
prevGames.map(g => g.id === gameId ? { ...g, installing: true } : g) prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
) )
executeDlcConfirm(dlcsCopy, gameId, isEditMode, games) executeDlcConfirm(dlcsCopy, gameId, isEditMode, games)
.then(() => { .then(() => {
success(isEditMode success(
? "DLC configuration updated successfully" isEditMode
: "CreamLinux installed with selected DLCs") ? 'DLC configuration updated successfully'
: 'CreamLinux installed with selected DLCs'
)
}) })
.catch(error => { .catch((error) => {
showError(`DLC operation failed: ${error}`) showError(`DLC operation failed: ${error}`)
}) })
.finally(() => { .finally(() => {
// Reset installing state // Reset installing state
setGames(prevGames => setGames((prevGames) =>
prevGames.map(g => g.id === gameId ? { ...g, installing: false } : g) prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
) )
}) })
} }
// Generic toast show function // Generic toast show function
const showToast = (message: string, type: 'success' | 'error' | 'warning' | 'info', options = {}) => { const showToast = (
message: string,
type: 'success' | 'error' | 'warning' | 'info',
options = {}
) => {
switch (type) { switch (type) {
case 'success': success(message, options); break; case 'success':
case 'error': showError(message, options); break; success(message, options)
case 'warning': warning(message, options); break; break
case 'info': info(message, options); break; case 'error':
showError(message, options)
break
case 'warning':
warning(message, options)
break
case 'info':
info(message, options)
break
} }
} }
@@ -159,20 +166,20 @@ export const AppProvider = ({ children }: AppProviderProps) => {
isLoading, isLoading,
error, error,
loadGames, loadGames,
// DLC management // DLC management
dlcDialog, dlcDialog,
handleGameEdit: (gameId: string) => { handleGameEdit: (gameId: string) => {
handleGameEdit(gameId, games) handleGameEdit(gameId, games)
}, },
handleDlcDialogClose: closeDlcDialog, handleDlcDialogClose: closeDlcDialog,
// Game actions // Game actions
progressDialog, progressDialog,
handleGameAction, handleGameAction,
handleDlcConfirm, handleDlcConfirm,
handleProgressDialogClose: handleCloseProgressDialog, handleProgressDialogClose: handleCloseProgressDialog,
// Toast notifications // Toast notifications
showToast, showToast,
} }
@@ -183,4 +190,4 @@ export const AppProvider = ({ children }: AppProviderProps) => {
<ToastContainer toasts={toasts} onDismiss={removeToast} /> <ToastContainer toasts={toasts} onDismiss={removeToast} />
</AppContext.Provider> </AppContext.Provider>
) )
} }

View File

@@ -1,3 +1,3 @@
export * from './AppContext'; export * from './AppContext'
export * from './AppProvider'; export * from './AppProvider'
export * from './useAppContext'; export * from './useAppContext'

View File

@@ -7,10 +7,10 @@ import { AppContext, AppContextType } from './AppContext'
*/ */
export const useAppContext = (): AppContextType => { export const useAppContext = (): AppContextType => {
const context = useContext(AppContext) const context = useContext(AppContext)
if (context === undefined) { if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider') throw new Error('useAppContext must be used within an AppProvider')
} }
return context return context
} }

View File

@@ -1,10 +1,10 @@
// Export all hooks // Export all hooks
export { useGames } from './useGames'; export { useGames } from './useGames'
export { useDlcManager } from './useDlcManager'; export { useDlcManager } from './useDlcManager'
export { useGameActions } from './useGameActions'; export { useGameActions } from './useGameActions'
export { useToasts } from './useToasts'; export { useToasts } from './useToasts'
export { useAppLogic } from './useAppLogic'; export { useAppLogic } from './useAppLogic'
// Export types // Export types
export type { ToastType, Toast, ToastOptions } from './useToasts'; export type { ToastType, Toast, ToastOptions } from './useToasts'
export type { DlcDialogState } from './useDlcManager'; export type { DlcDialogState } from './useDlcManager'

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'
import { useAppContext } from '@/contexts/useAppContext' import { useAppContext } from '@/contexts/useAppContext'
interface UseAppLogicOptions { interface UseAppLogicOptions {
autoLoad?: boolean; autoLoad?: boolean
} }
/** /**
@@ -11,24 +11,18 @@ interface UseAppLogicOptions {
*/ */
export function useAppLogic(options: UseAppLogicOptions = {}) { export function useAppLogic(options: UseAppLogicOptions = {}) {
const { autoLoad = true } = options const { autoLoad = true } = options
// Get values from app context // Get values from app context
const { const { games, loadGames, isLoading, error, showToast } = useAppContext()
games,
loadGames,
isLoading,
error,
showToast
} = useAppContext()
// Local state for filtering and UI // Local state for filtering and UI
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isInitialLoad, setIsInitialLoad] = useState(true) const [isInitialLoad, setIsInitialLoad] = useState(true)
const isInitializedRef = useRef(false) const isInitializedRef = useRef(false)
const [scanProgress, setScanProgress] = useState({ const [scanProgress, setScanProgress] = useState({
message: 'Initializing...', message: 'Initializing...',
progress: 0 progress: 0,
}) })
// Filter games based on current filter and search // Filter games based on current filter and search
@@ -41,8 +35,8 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
(filter === 'proton' && !game.native) (filter === 'proton' && !game.native)
// Then filter by search query // Then filter by search query
const searchMatch = !searchQuery.trim() || const searchMatch =
game.title.toLowerCase().includes(searchQuery.toLowerCase()) !searchQuery.trim() || game.title.toLowerCase().includes(searchQuery.toLowerCase())
return platformMatch && searchMatch return platformMatch && searchMatch
}) })
@@ -52,25 +46,25 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
const handleSearchChange = useCallback((query: string) => { const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query) setSearchQuery(query)
}, []) }, [])
// Handle initial loading with simulated progress // Handle initial loading with simulated progress
useEffect(() => { useEffect(() => {
if (!autoLoad || !isInitialLoad || isInitializedRef.current) return if (!autoLoad || !isInitialLoad || isInitializedRef.current) return
isInitializedRef.current = true isInitializedRef.current = true
console.log("[APP LOGIC #2] Starting initialization") console.log('[APP LOGIC #2] Starting initialization')
const initialLoad = async () => { const initialLoad = async () => {
try { try {
setScanProgress({ message: 'Scanning for games...', progress: 20 }) setScanProgress({ message: 'Scanning for games...', progress: 20 })
await new Promise(resolve => setTimeout(resolve, 800)) await new Promise((resolve) => setTimeout(resolve, 800))
setScanProgress({ message: 'Loading game information...', progress: 50 }) setScanProgress({ message: 'Loading game information...', progress: 50 })
await loadGames() await loadGames()
setScanProgress({ message: 'Finishing up...', progress: 90 }) setScanProgress({ message: 'Finishing up...', progress: 90 })
await new Promise(resolve => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
setScanProgress({ message: 'Ready!', progress: 100 }) setScanProgress({ message: 'Ready!', progress: 100 })
setTimeout(() => setIsInitialLoad(false), 500) setTimeout(() => setIsInitialLoad(false), 500)
} catch (error) { } catch (error) {
@@ -79,10 +73,10 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
setTimeout(() => setIsInitialLoad(false), 2000) setTimeout(() => setIsInitialLoad(false), 2000)
} }
} }
initialLoad() initialLoad()
}, [autoLoad, isInitialLoad, loadGames, showToast]) }, [autoLoad, isInitialLoad, loadGames, showToast])
// Force a refresh // Force a refresh
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
try { try {
@@ -104,6 +98,6 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
filteredGames: filteredGames(), filteredGames: filteredGames(),
handleRefresh, handleRefresh,
isLoading, isLoading,
error error,
} }
} }

View File

@@ -4,17 +4,17 @@ import { listen } from '@tauri-apps/api/event'
import { Game, DlcInfo } from '@/types' import { Game, DlcInfo } from '@/types'
export interface DlcDialogState { export interface DlcDialogState {
visible: boolean; visible: boolean
gameId: string; gameId: string
gameTitle: string; gameTitle: string
dlcs: DlcInfo[]; dlcs: DlcInfo[]
enabledDlcs: string[]; enabledDlcs: string[]
isLoading: boolean; isLoading: boolean
isEditMode: boolean; isEditMode: boolean
progress: number; progress: number
progressMessage: string; progressMessage: string
timeLeft: string; timeLeft: string
error: string | null; error: string | null
} }
/** /**
@@ -60,9 +60,9 @@ export function useDlcManager() {
// When progress is 100%, mark loading as complete and reset fetch state // When progress is 100%, mark loading as complete and reset fetch state
const unlistenDlcProgress = await listen<{ const unlistenDlcProgress = await listen<{
message: string; message: string
progress: number; progress: number
timeLeft?: string; timeLeft?: string
}>('dlc-progress', (event) => { }>('dlc-progress', (event) => {
const { message, progress, timeLeft } = event.payload const { message, progress, timeLeft } = event.payload
@@ -196,9 +196,9 @@ export function useDlcManager() {
if (allDlcs.length > 0) { if (allDlcs.length > 0) {
// Log the fresh DLC config // Log the fresh DLC config
console.log('Loaded existing DLC configuration:', allDlcs) console.log('Loaded existing DLC configuration:', allDlcs)
// IMPORTANT: Create a completely new array to avoid reference issues // IMPORTANT: Create a completely new array to avoid reference issues
const freshDlcs = allDlcs.map(dlc => ({...dlc})) const freshDlcs = allDlcs.map((dlc) => ({ ...dlc }))
setDlcDialog((prev) => ({ setDlcDialog((prev) => ({
...prev, ...prev,
@@ -207,7 +207,7 @@ export function useDlcManager() {
progress: 100, progress: 100,
progressMessage: 'Loaded existing DLC configuration', progressMessage: 'Loaded existing DLC configuration',
})) }))
// Reset force reload flag // Reset force reload flag
setForceReload(false) setForceReload(false)
return return
@@ -279,12 +279,12 @@ export function useDlcManager() {
} }
// Close dialog and reset state // Close dialog and reset state
setDlcDialog((prev) => ({ setDlcDialog((prev) => ({
...prev, ...prev,
visible: false, visible: false,
dlcs: [], // Clear DLCs to force a reload next time dlcs: [], // Clear DLCs to force a reload next time
})) }))
// Set flag to force reload next time // Set flag to force reload next time
setForceReload(true) setForceReload(true)
} }
@@ -311,4 +311,4 @@ export function useDlcManager() {
handleDlcDialogClose, handleDlcDialogClose,
forceReload, forceReload,
} }
} }

View File

@@ -26,16 +26,17 @@ export function useGameActions() {
try { try {
// Listen for progress updates from the backend // Listen for progress updates from the backend
const unlistenProgress = await listen<{ const unlistenProgress = await listen<{
title: string; title: string
message: string; message: string
progress: number; progress: number
complete: boolean; complete: boolean
show_instructions?: boolean; show_instructions?: boolean
instructions?: InstallationInstructions; instructions?: InstallationInstructions
}>('installation-progress', (event) => { }>('installation-progress', (event) => {
console.log('Received installation-progress event:', event) console.log('Received installation-progress event:', event)
const { title, message, progress, complete, show_instructions, instructions } = event.payload const { title, message, progress, complete, show_instructions, instructions } =
event.payload
if (complete && !show_instructions) { if (complete && !show_instructions) {
// Hide dialog when complete if no instructions // Hide dialog when complete if no instructions
@@ -64,7 +65,7 @@ export function useGameActions() {
let cleanup: (() => void) | null = null let cleanup: (() => void) | null = null
setupEventListeners().then(unlisten => { setupEventListeners().then((unlisten) => {
cleanup = unlisten cleanup = unlisten
}) })
@@ -79,156 +80,156 @@ export function useGameActions() {
}, []) }, [])
// Unified handler for game actions (install/uninstall) // Unified handler for game actions (install/uninstall)
const handleGameAction = useCallback(async (gameId: string, action: ActionType, games: Game[]) => { const handleGameAction = useCallback(
try { async (gameId: string, action: ActionType, games: Game[]) => {
// For CreamLinux installation, we should NOT call process_game_action directly try {
// Instead, we show the DLC selection dialog first, which is handled in AppProvider // For CreamLinux installation, we should NOT call process_game_action directly
if (action === 'install_cream') { // Instead, we show the DLC selection dialog first, which is handled in AppProvider
return if (action === 'install_cream') {
} return
}
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
// Find game to get title
const game = games.find((g) => g.id === gameId)
if (!game) return
// Get title based on action // For other actions (uninstall_cream, install_smoke, uninstall_smoke)
const isCream = action.includes('cream') // Find game to get title
const isInstall = action.includes('install') const game = games.find((g) => g.id === gameId)
const product = isCream ? 'CreamLinux' : 'SmokeAPI' if (!game) return
const operation = isInstall ? 'Installing' : 'Uninstalling'
// Show progress dialog // Get title based on action
setProgressDialog({ const isCream = action.includes('cream')
visible: true, const isInstall = action.includes('install')
title: `${operation} ${product} for ${game.title}`, const product = isCream ? 'CreamLinux' : 'SmokeAPI'
message: isInstall ? 'Downloading required files...' : 'Removing files...', const operation = isInstall ? 'Installing' : 'Uninstalling'
progress: isInstall ? 0 : 30,
showInstructions: false,
instructions: undefined,
})
console.log(`Invoking process_game_action for game ${gameId} with action ${action}`) // Show progress dialog
// Call the backend with the unified action
await invoke('process_game_action', {
gameAction: {
game_id: gameId,
action,
},
})
} catch (error) {
console.error(`Error processing action ${action} for game ${gameId}:`, error)
// Show error in progress dialog
setProgressDialog((prev) => ({
...prev,
message: `Error: ${error}`,
progress: 100,
}))
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 3000)
// Rethrow to allow upstream handling
throw error
}
}, [])
// Handle DLC selection confirmation
const handleDlcConfirm = useCallback(async (
selectedDlcs: DlcInfo[],
gameId: string,
isEditMode: boolean,
games: Game[]
) => {
// Find the game
const game = games.find((g) => g.id === gameId)
if (!game) return
try {
if (isEditMode) {
// MODIFIED: Create a deep copy to ensure we don't have reference issues
const dlcsCopy = selectedDlcs.map(dlc => ({...dlc}));
// Show progress dialog for editing
setProgressDialog({ setProgressDialog({
visible: true, visible: true,
title: `Updating DLCs for ${game.title}`, title: `${operation} ${product} for ${game.title}`,
message: 'Updating DLC configuration...', message: isInstall ? 'Downloading required files...' : 'Removing files...',
progress: 30, progress: isInstall ? 0 : 30,
showInstructions: false, showInstructions: false,
instructions: undefined, instructions: undefined,
}) })
console.log('Saving DLC configuration:', dlcsCopy) console.log(`Invoking process_game_action for game ${gameId} with action ${action}`)
// Call the backend to update the DLC configuration // Call the backend with the unified action
await invoke('update_dlc_configuration_command', { await invoke('process_game_action', {
gamePath: game.path, gameAction: {
dlcs: dlcsCopy, game_id: gameId,
action,
},
}) })
} catch (error) {
// Update progress dialog for completion console.error(`Error processing action ${action} for game ${gameId}:`, error)
// Show error in progress dialog
setProgressDialog((prev) => ({ setProgressDialog((prev) => ({
...prev, ...prev,
title: `Update Complete: ${game.title}`, message: `Error: ${error}`,
message: 'DLC configuration updated successfully!',
progress: 100, progress: 100,
})) }))
// Hide dialog after a delay // Hide dialog after a delay
setTimeout(() => { setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false })) setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 2000) }, 3000)
} else {
// We're doing a fresh install with selected DLCs // Rethrow to allow upstream handling
// Show progress dialog for installation right away throw error
setProgressDialog({
visible: true,
title: `Installing CreamLinux for ${game.title}`,
message: 'Preparing to download CreamLinux...',
progress: 0,
showInstructions: false,
instructions: undefined,
})
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs,
})
// Note: The progress dialog will be updated through the installation-progress event listener
} }
} catch (error) { },
console.error('Error processing DLC selection:', error) []
)
// Show error in progress dialog
setProgressDialog((prev) => ({ // Handle DLC selection confirmation
...prev, const handleDlcConfirm = useCallback(
message: `Error: ${error}`, async (selectedDlcs: DlcInfo[], gameId: string, isEditMode: boolean, games: Game[]) => {
progress: 100, // Find the game
})) const game = games.find((g) => g.id === gameId)
if (!game) return
// Hide dialog after a delay
setTimeout(() => { try {
setProgressDialog((prev) => ({ ...prev, visible: false })) if (isEditMode) {
}, 3000) // MODIFIED: Create a deep copy to ensure we don't have reference issues
const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc }))
// Rethrow to allow upstream handling
throw error // Show progress dialog for editing
} setProgressDialog({
}, []) visible: true,
title: `Updating DLCs for ${game.title}`,
message: 'Updating DLC configuration...',
progress: 30,
showInstructions: false,
instructions: undefined,
})
console.log('Saving DLC configuration:', dlcsCopy)
// Call the backend to update the DLC configuration
await invoke('update_dlc_configuration_command', {
gamePath: game.path,
dlcs: dlcsCopy,
})
// Update progress dialog for completion
setProgressDialog((prev) => ({
...prev,
title: `Update Complete: ${game.title}`,
message: 'DLC configuration updated successfully!',
progress: 100,
}))
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 2000)
} else {
// We're doing a fresh install with selected DLCs
// Show progress dialog for installation right away
setProgressDialog({
visible: true,
title: `Installing CreamLinux for ${game.title}`,
message: 'Preparing to download CreamLinux...',
progress: 0,
showInstructions: false,
instructions: undefined,
})
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs,
})
// Note: The progress dialog will be updated through the installation-progress event listener
}
} catch (error) {
console.error('Error processing DLC selection:', error)
// Show error in progress dialog
setProgressDialog((prev) => ({
...prev,
message: `Error: ${error}`,
progress: 100,
}))
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 3000)
// Rethrow to allow upstream handling
throw error
}
},
[]
)
return { return {
progressDialog, progressDialog,
setProgressDialog, setProgressDialog,
handleCloseProgressDialog, handleCloseProgressDialog,
handleGameAction, handleGameAction,
handleDlcConfirm handleDlcConfirm,
} }
} }

View File

@@ -64,17 +64,15 @@ export function useGames() {
// Update only the specific game in the state // Update only the specific game in the state
setGames((prevGames) => setGames((prevGames) =>
prevGames.map((game) => prevGames.map((game) =>
game.id === updatedGame.id game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game
? { ...updatedGame, platform: 'Steam' }
: game
) )
) )
}) })
// Listen for scan progress events // Listen for scan progress events
const unlistenScanProgress = await listen<{ const unlistenScanProgress = await listen<{
message: string; message: string
progress: number; progress: number
}>('scan-progress', (event) => { }>('scan-progress', (event) => {
const { message, progress } = event.payload const { message, progress } = event.payload
@@ -102,7 +100,7 @@ export function useGames() {
// Cleanup function // Cleanup function
return () => { return () => {
unlisteners.forEach(fn => fn()) unlisteners.forEach((fn) => fn())
} }
}, [loadGames, isInitialLoad]) }, [loadGames, isInitialLoad])
@@ -123,4 +121,4 @@ export function useGames() {
updateGame, updateGame,
setGames, setGames,
} }
} }

View File

@@ -10,19 +10,19 @@ export type ToastType = 'success' | 'error' | 'warning' | 'info'
* Toast interface * Toast interface
*/ */
export interface Toast { export interface Toast {
id: string; id: string
message: string; message: string
type: ToastType; type: ToastType
duration?: number; duration?: number
title?: string; title?: string
} }
/** /**
* Toast options interface * Toast options interface
*/ */
export interface ToastOptions { export interface ToastOptions {
title?: string; title?: string
duration?: number; duration?: number
} }
/** /**
@@ -36,51 +36,66 @@ export function useToasts() {
* Removes a toast by ID * Removes a toast by ID
*/ */
const removeToast = useCallback((id: string) => { const removeToast = useCallback((id: string) => {
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)) setToasts((currentToasts) => currentToasts.filter((toast) => toast.id !== id))
}, []) }, [])
/** /**
* Adds a new toast with the specified type and options * Adds a new toast with the specified type and options
*/ */
const addToast = useCallback((toast: Omit<Toast, 'id'>) => { const addToast = useCallback(
const id = uuidv4() (toast: Omit<Toast, 'id'>) => {
const newToast = { ...toast, id } const id = uuidv4()
const newToast = { ...toast, id }
setToasts(currentToasts => [...currentToasts, newToast])
setToasts((currentToasts) => [...currentToasts, newToast])
// Auto-remove toast after its duration expires
if (toast.duration !== Infinity) { // Auto-remove toast after its duration expires
setTimeout(() => { if (toast.duration !== Infinity) {
removeToast(id) setTimeout(() => {
}, toast.duration || 5000) // Default 5 seconds removeToast(id)
} }, toast.duration || 5000) // Default 5 seconds
}
return id
}, [removeToast]) return id
},
[removeToast]
)
/** /**
* Shorthand method for success toasts * Shorthand method for success toasts
*/ */
const success = useCallback((message: string, options: ToastOptions = {}) => const success = useCallback(
addToast({ message, type: 'success', ...options }), [addToast]) (message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'success', ...options }),
[addToast]
)
/** /**
* Shorthand method for error toasts * Shorthand method for error toasts
*/ */
const error = useCallback((message: string, options: ToastOptions = {}) => const error = useCallback(
addToast({ message, type: 'error', ...options }), [addToast]) (message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'error', ...options }),
[addToast]
)
/** /**
* Shorthand method for warning toasts * Shorthand method for warning toasts
*/ */
const warning = useCallback((message: string, options: ToastOptions = {}) => const warning = useCallback(
addToast({ message, type: 'warning', ...options }), [addToast]) (message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'warning', ...options }),
[addToast]
)
/** /**
* Shorthand method for info toasts * Shorthand method for info toasts
*/ */
const info = useCallback((message: string, options: ToastOptions = {}) => const info = useCallback(
addToast({ message, type: 'info', ...options }), [addToast]) (message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'info', ...options }),
[addToast]
)
return { return {
toasts, toasts,
@@ -91,4 +106,4 @@ export function useToasts() {
warning, warning,
info, info,
} }
} }

View File

@@ -9,4 +9,4 @@ createRoot(document.getElementById('root')!).render(
<App /> <App />
</AppProvider> </AppProvider>
</StrictMode> </StrictMode>
) )

View File

@@ -2,10 +2,10 @@
* Game image sources from Steam's CDN * Game image sources from Steam's CDN
*/ */
export const SteamImageType = { export const SteamImageType = {
HEADER: 'header', // 460x215 HEADER: 'header', // 460x215
CAPSULE: 'capsule_616x353', // 616x353 CAPSULE: 'capsule_616x353', // 616x353
LOGO: 'logo', // Game logo with transparency LOGO: 'logo', // Game logo with transparency
LIBRARY_HERO: 'library_hero', // 1920x620 LIBRARY_HERO: 'library_hero', // 1920x620
LIBRARY_CAPSULE: 'library_600x900', // 600x900 LIBRARY_CAPSULE: 'library_600x900', // 600x900
} as const } as const
@@ -68,16 +68,12 @@ export const findBestGameImage = async (appId: string): Promise<string | null> =
} }
// Try these image types in order of preference // Try these image types in order of preference
const typesToTry = [ const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
]
for (const type of typesToTry) { for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type) const url = getSteamImageUrl(appId, type)
const exists = await checkImageExists(url) const exists = await checkImageExists(url)
if (exists) { if (exists) {
try { try {
// Preload the image to prevent flickering // Preload the image to prevent flickering
@@ -95,4 +91,4 @@ export const findBestGameImage = async (appId: string): Promise<string | null> =
// If no valid image was found // If no valid image was found
return null return null
} }

View File

@@ -1 +1 @@
export * from './ImageService'; export * from './ImageService'

View File

@@ -4,7 +4,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('../assets/fonts/Satoshi.ttf') format('ttf'), src:
url('../assets/fonts/Satoshi.ttf') format('ttf'),
url('../assets/fonts/Roboto.ttf') format('ttf'), url('../assets/fonts/Roboto.ttf') format('ttf'),
url('../assets/fonts/WorkSans.ttf') format('ttf'); url('../assets/fonts/WorkSans.ttf') format('ttf');
font-weight: 400; font-weight: 400;

View File

@@ -22,11 +22,8 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: radial-gradient( background-image:
circle at 20% 30%, radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
rgba(var(--primary-color), 0.05) 0%,
transparent 70%
),
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%); radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
pointer-events: none; pointer-events: none;
z-index: var(--z-bg); z-index: var(--z-bg);

View File

@@ -36,7 +36,9 @@
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out; transition:
transform 0.2s var(--easing-bounce),
opacity 0.2s ease-out;
cursor: default; cursor: default;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -45,11 +45,15 @@
// Special styling for cards with different statuses // Special styling for cards with different statuses
.game-item-card:has(.status-badge.cream) { .game-item-card:has(.status-badge.cream) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15); box-shadow:
var(--shadow-standard),
0 0 15px rgba(128, 181, 255, 0.15);
} }
.game-item-card:has(.status-badge.smoke) { .game-item-card:has(.status-badge.smoke) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15); box-shadow:
var(--shadow-standard),
0 0 15px rgba(255, 239, 150, 0.15);
} }
// Game item overlay // Game item overlay

View File

@@ -37,7 +37,9 @@
// Interactive icons // Interactive icons
&.icon-clickable { &.icon-clickable {
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease; transition:
transform 0.2s ease,
opacity 0.2s ease;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;

View File

@@ -96,7 +96,9 @@
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow:
0 0 0 2px rgba(var(--primary-color), 0.3),
inset 0 2px 5px rgba(0, 0, 0, 0.2);
& + .search-icon { & + .search-icon {
color: var(--primary-color); color: var(--primary-color);

View File

@@ -2,7 +2,7 @@
* DLC information interface * DLC information interface
*/ */
export interface DlcInfo { export interface DlcInfo {
appid: string; appid: string
name: string; name: string
enabled: boolean; enabled: boolean
} }

View File

@@ -2,13 +2,13 @@
* Game information interface * Game information interface
*/ */
export interface Game { export interface Game {
id: string; id: string
title: string; title: string
path: string; path: string
platform?: string; platform?: string
native: boolean; native: boolean
api_files: string[]; api_files: string[]
cream_installed?: boolean; cream_installed?: boolean
smoke_installed?: boolean; smoke_installed?: boolean
installing?: boolean; installing?: boolean
} }

View File

@@ -1,2 +1,2 @@
export * from './Game' export * from './Game'
export * from './DlcInfo' export * from './DlcInfo'

6
src/types/svg.d.ts vendored
View File

@@ -2,11 +2,11 @@
declare module '*.svg' { declare module '*.svg' {
import React from 'react' import React from 'react'
export const ReactComponent: React.FunctionComponent< export const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement> & { title?: string } React.SVGProps<SVGSVGElement> & { title?: string }
> >
const src: string const src: string
export default src export default src
} }
@@ -29,4 +29,4 @@ declare module '*.jpeg' {
declare module '*.gif' { declare module '*.gif' {
const content: string const content: string
export default content export default content
} }

View File

@@ -9,17 +9,17 @@
*/ */
export function formatTime(seconds: number): string { export function formatTime(seconds: number): string {
if (seconds < 60) { if (seconds < 60) {
return `${Math.round(seconds)}s`; return `${Math.round(seconds)}s`
} }
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.round(seconds % 60); const remainingSeconds = Math.round(seconds % 60)
if (remainingSeconds === 0) { if (remainingSeconds === 0) {
return `${minutes}m`; return `${minutes}m`
} }
return `${minutes}m ${remainingSeconds}s`; return `${minutes}m ${remainingSeconds}s`
} }
/** /**
@@ -31,10 +31,10 @@ export function formatTime(seconds: number): string {
*/ */
export function truncateString(str: string, maxLength: number, suffix: string = '...'): string { export function truncateString(str: string, maxLength: number, suffix: string = '...'): string {
if (str.length <= maxLength) { if (str.length <= maxLength) {
return str; return str
} }
return str.substring(0, maxLength - suffix.length) + suffix; return str.substring(0, maxLength - suffix.length) + suffix
} }
/** /**
@@ -47,17 +47,17 @@ export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T, fn: T,
delay: number delay: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null
return function(...args: Parameters<T>) { return function (...args: Parameters<T>) {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer)
} }
timer = setTimeout(() => { timer = setTimeout(() => {
fn(...args); fn(...args)
}, delay); }, delay)
}; }
} }
/** /**
@@ -70,16 +70,16 @@ export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T, fn: T,
limit: number limit: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let lastCall = 0; let lastCall = 0
return function(...args: Parameters<T>) { return function (...args: Parameters<T>) {
const now = Date.now(); const now = Date.now()
if (now - lastCall < limit) { if (now - lastCall < limit) {
return; return
} }
lastCall = now; lastCall = now
return fn(...args); return fn(...args)
}; }
} }

View File

@@ -1 +1 @@
export * from './helpers'; export * from './helpers'

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import svgr from "vite-plugin-svgr"; import svgr from 'vite-plugin-svgr'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -10,7 +10,7 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
}, },
}, },
plugins: [ plugins: [
react(), react(),
svgr({ svgr({
@@ -36,4 +36,4 @@ export default defineConfig({
minify: 'esbuild', minify: 'esbuild',
sourcemap: true, sourcemap: true,
}, },
}) })