Initial changes

This commit is contained in:
Tickbase
2025-05-18 08:06:56 +02:00
parent 19087c00da
commit 0be15f83e7
82 changed files with 4636 additions and 3237 deletions

10
src/hooks/index.ts Normal file
View File

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

119
src/hooks/useAppLogic.ts Normal file
View File

@@ -0,0 +1,119 @@
import { useState, useCallback, useEffect } from 'react'
import { useAppContext } from '@/contexts/useAppContext'
interface UseAppLogicOptions {
autoLoad?: boolean;
}
/**
* Main application logic hook
* Combines various aspects of the app's behavior
*/
export function useAppLogic(options: UseAppLogicOptions = {}) {
const { autoLoad = true } = options
// Get values from app context
const {
games,
loadGames,
isLoading,
error,
showToast
} = useAppContext()
// Local state for filtering and UI
const [filter, setFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [scanProgress, setScanProgress] = useState({
message: 'Initializing...',
progress: 0
})
// Filter games based on current filter and search
const filteredGames = useCallback(() => {
return games.filter((game) => {
// First filter by platform type
const platformMatch =
filter === 'all' ||
(filter === 'native' && game.native) ||
(filter === 'proton' && !game.native)
// Then filter by search query
const searchMatch = !searchQuery.trim() ||
game.title.toLowerCase().includes(searchQuery.toLowerCase())
return platformMatch && searchMatch
})
}, [games, filter, searchQuery])
// Handle search changes
const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// Handle initial loading with simulated progress
useEffect(() => {
if (autoLoad && isInitialLoad) {
const initialLoad = async () => {
try {
// Show scanning message
setScanProgress({ message: 'Scanning for games...', progress: 20 })
// Small delay to show loading screen
await new Promise(resolve => setTimeout(resolve, 800))
// Update progress
setScanProgress({ message: 'Loading game information...', progress: 50 })
// Load games data
await loadGames()
// Update progress
setScanProgress({ message: 'Finishing up...', progress: 90 })
// Small delay for animation
await new Promise(resolve => setTimeout(resolve, 500))
// Complete
setScanProgress({ message: 'Ready!', progress: 100 })
// Exit loading screen after a moment
setTimeout(() => setIsInitialLoad(false), 500)
} catch (error) {
setScanProgress({ message: `Error: ${error}`, progress: 100 })
showToast(`Failed to load: ${error}`, 'error')
// Allow exit even on error
setTimeout(() => setIsInitialLoad(false), 2000)
}
}
initialLoad()
}
}, [autoLoad, isInitialLoad, loadGames, showToast])
// Force a refresh
const handleRefresh = useCallback(async () => {
try {
await loadGames()
showToast('Game list refreshed', 'success')
} catch (error) {
showToast(`Failed to refresh: ${error}`, 'error')
}
}, [loadGames, showToast])
return {
filter,
setFilter,
searchQuery,
handleSearchChange,
isInitialLoad,
setIsInitialLoad,
scanProgress,
filteredGames: filteredGames(),
handleRefresh,
isLoading,
error
}
}

295
src/hooks/useDlcManager.ts Normal file
View File

@@ -0,0 +1,295 @@
import { useState, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { Game, DlcInfo } from '@/types'
export interface DlcDialogState {
visible: boolean;
gameId: string;
gameTitle: string;
dlcs: DlcInfo[];
enabledDlcs: string[];
isLoading: boolean;
isEditMode: boolean;
progress: number;
progressMessage: string;
timeLeft: string;
error: string | null;
}
/**
* Hook for managing DLC functionality
* Handles fetching, filtering, and updating DLCs
*/
export function useDlcManager() {
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false)
const dlcFetchController = useRef<AbortController | null>(null)
const activeDlcFetchId = useRef<string | null>(null)
// DLC selection dialog state
const [dlcDialog, setDlcDialog] = useState<DlcDialogState>({
visible: false,
gameId: '',
gameTitle: '',
dlcs: [],
enabledDlcs: [],
isLoading: false,
isEditMode: false,
progress: 0,
progressMessage: '',
timeLeft: '',
error: null,
})
// Set up event listeners for DLC streaming
useEffect(() => {
// Listen for individual DLC found events
const setupDlcEventListeners = async () => {
try {
// This event is emitted for each DLC as it's found
const unlistenDlcFound = await listen<string>('dlc-found', (event) => {
const dlc = JSON.parse(event.payload) as { appid: string; name: string }
// Add the DLC to the current list with enabled=true
setDlcDialog((prev) => ({
...prev,
dlcs: [...prev.dlcs, { ...dlc, enabled: true }],
}))
})
// When progress is 100%, mark loading as complete and reset fetch state
const unlistenDlcProgress = await listen<{
message: string;
progress: number;
timeLeft?: string;
}>('dlc-progress', (event) => {
const { message, progress, timeLeft } = event.payload
// Update the progress indicator
setDlcDialog((prev) => ({
...prev,
progress,
progressMessage: message,
timeLeft: timeLeft || '',
}))
// If progress is 100%, mark loading as complete
if (progress === 100) {
setTimeout(() => {
setDlcDialog((prev) => ({
...prev,
isLoading: false,
}))
// Reset fetch state
setIsFetchingDlcs(false)
activeDlcFetchId.current = null
}, 500)
}
})
// This event is emitted if there's an error
const unlistenDlcError = await listen<{ error: string }>('dlc-error', (event) => {
const { error } = event.payload
console.error('DLC streaming error:', error)
// Show error in dialog
setDlcDialog((prev) => ({
...prev,
error,
isLoading: false,
}))
})
return () => {
unlistenDlcFound()
unlistenDlcProgress()
unlistenDlcError()
}
} catch (error) {
console.error('Error setting up DLC event listeners:', error)
return () => {}
}
}
const cleanup = setupDlcEventListeners()
return () => {
cleanup.then((fn) => fn())
}
}, [])
// Clean up if component unmounts during a fetch
useEffect(() => {
return () => {
// Clean up any ongoing fetch operations
if (dlcFetchController.current) {
dlcFetchController.current.abort()
dlcFetchController.current = null
}
}
}, [])
// Function to fetch DLCs for a game with streaming updates
const streamGameDlcs = async (gameId: string): Promise<void> => {
try {
// Set up flag to indicate we're fetching DLCs
setIsFetchingDlcs(true)
activeDlcFetchId.current = gameId
// Start streaming DLCs - this won't return DLCs directly
// Instead, it triggers events that we'll listen for
await invoke('stream_game_dlcs', { gameId })
return
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('DLC fetching was aborted')
} else {
console.error('Error starting DLC stream:', error)
throw error
}
} finally {
// Reset state when done or on error
setIsFetchingDlcs(false)
activeDlcFetchId.current = null
}
}
// Handle game edit (show DLC management dialog)
const handleGameEdit = async (gameId: string, games: Game[]) => {
const game = games.find((g) => g.id === gameId)
if (!game || !game.cream_installed) return
// Check if we're already fetching DLCs for this game
if (isFetchingDlcs && activeDlcFetchId.current === gameId) {
console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`)
return
}
try {
// Show dialog immediately with empty DLC list
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [],
enabledDlcs: [],
isLoading: true,
isEditMode: true,
progress: 0,
progressMessage: 'Reading DLC configuration...',
timeLeft: '',
error: null,
})
// Try to read all DLCs from the configuration file first (including disabled ones)
try {
const allDlcs = await invoke<DlcInfo[]>('get_all_dlcs_command', {
gamePath: game.path,
}).catch(() => [] as DlcInfo[])
if (allDlcs.length > 0) {
// If we have DLCs from the config file, use them
console.log('Loaded existing DLC configuration:', allDlcs)
setDlcDialog((prev) => ({
...prev,
dlcs: allDlcs,
isLoading: false,
progress: 100,
progressMessage: 'Loaded existing DLC configuration',
}))
return
}
} catch (error) {
console.warn('Could not read existing DLC configuration, falling back to API:', error)
// Continue with API loading if config reading fails
}
// Mark that we're fetching DLCs for this game
setIsFetchingDlcs(true)
activeDlcFetchId.current = gameId
// Create abort controller for fetch operation
dlcFetchController.current = new AbortController()
// Start streaming DLCs
await streamGameDlcs(gameId).catch((error) => {
if (error.name !== 'AbortError') {
console.error('Error streaming DLCs:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false,
}))
}
})
// Try to get the enabled DLCs
const enabledDlcs = await invoke<string[]>('get_enabled_dlcs_command', {
gamePath: game.path,
}).catch(() => [] as string[])
// We'll update the enabled state of DLCs as they come in
setDlcDialog((prev) => ({
...prev,
enabledDlcs,
}))
} catch (error) {
console.error('Error preparing DLC edit:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to prepare DLC editor: ${error}`,
isLoading: false,
}))
}
}
// Handle DLC selection dialog close
const handleDlcDialogClose = () => {
// Cancel any in-progress DLC fetching
if (isFetchingDlcs && activeDlcFetchId.current) {
console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`)
// This will signal to the Rust backend that we want to stop the process
invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current }).catch((err) =>
console.error('Error aborting DLC fetch:', err)
)
// Reset state
activeDlcFetchId.current = null
setIsFetchingDlcs(false)
}
// Clear controller
if (dlcFetchController.current) {
dlcFetchController.current.abort()
dlcFetchController.current = null
}
// Close dialog
setDlcDialog((prev) => ({ ...prev, visible: false }))
}
// Update DLCs being streamed with enabled state
useEffect(() => {
if (dlcDialog.enabledDlcs.length > 0) {
setDlcDialog((prev) => ({
...prev,
dlcs: prev.dlcs.map((dlc) => ({
...dlc,
enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid),
})),
}))
}
}, [dlcDialog.dlcs, dlcDialog.enabledDlcs])
return {
dlcDialog,
setDlcDialog,
isFetchingDlcs,
streamGameDlcs,
handleGameEdit,
handleDlcDialogClose,
}
}

221
src/hooks/useGameActions.ts Normal file
View File

@@ -0,0 +1,221 @@
import { useState, useCallback, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { ActionType } from '@/components/buttons/ActionButton'
import { Game, DlcInfo } from '@/types'
import { InstallationInstructions } from '@/contexts/AppContext'
/**
* Hook for managing game action operations
* Handles installation, uninstallation, and progress tracking
*/
export function useGameActions() {
// Progress dialog state
const [progressDialog, setProgressDialog] = useState({
visible: false,
title: '',
message: '',
progress: 0,
showInstructions: false,
instructions: undefined as InstallationInstructions | undefined,
})
// Set up event listeners for progress updates
useEffect(() => {
const setupEventListeners = async () => {
try {
// Listen for progress updates from the backend
const unlistenProgress = await listen<{
title: string;
message: string;
progress: number;
complete: boolean;
show_instructions?: boolean;
instructions?: InstallationInstructions;
}>('installation-progress', (event) => {
console.log('Received installation-progress event:', event)
const { title, message, progress, complete, show_instructions, instructions } = event.payload
if (complete && !show_instructions) {
// Hide dialog when complete if no instructions
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 1000)
} else {
// Update progress dialog
setProgressDialog({
visible: true,
title,
message,
progress,
showInstructions: show_instructions || false,
instructions,
})
}
})
return unlistenProgress
} catch (error) {
console.error('Error setting up progress event listeners:', error)
return () => {}
}
}
let cleanup: (() => void) | null = null
setupEventListeners().then(unlisten => {
cleanup = unlisten
})
return () => {
if (cleanup) cleanup()
}
}, [])
// Handler function to close progress dialog
const handleCloseProgressDialog = useCallback(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, [])
// Unified handler for game actions (install/uninstall)
const handleGameAction = useCallback(async (gameId: string, action: ActionType, games: Game[]) => {
try {
// Find game to get title
const game = games.find((g) => g.id === gameId)
if (!game) return
// Get title based on action
const isCream = action.includes('cream')
const isInstall = action.includes('install')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
const operation = isInstall ? 'Installing' : 'Uninstalling'
// Show progress dialog
setProgressDialog({
visible: true,
title: `${operation} ${product} for ${game.title}`,
message: isInstall ? 'Downloading required files...' : 'Removing files...',
progress: isInstall ? 0 : 30,
showInstructions: false,
instructions: undefined,
})
console.log(`Invoking process_game_action for game ${gameId} with action ${action}`)
// 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) {
// If in edit mode, we're updating existing cream_api.ini
// Show progress dialog for editing
setProgressDialog({
visible: true,
title: `Updating DLCs for ${game.title}`,
message: 'Updating DLC configuration...',
progress: 30,
showInstructions: false,
instructions: undefined,
})
// Call the backend to update the DLC configuration
await invoke('update_dlc_configuration_command', {
gamePath: game.path,
dlcs: selectedDlcs,
})
// 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: 'Processing...',
progress: 0,
showInstructions: false,
instructions: undefined,
})
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs,
})
}
} 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 {
progressDialog,
setProgressDialog,
handleCloseProgressDialog,
handleGameAction,
handleDlcConfirm
}
}

126
src/hooks/useGames.ts Normal file
View File

@@ -0,0 +1,126 @@
import { useState, useCallback, useEffect } from 'react'
import { Game } from '@/types'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
/**
* Hook for managing games state and operations
* Handles game loading, scanning, and updates
*/
export function useGames() {
const [games, setGames] = useState<Game[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [scanProgress, setScanProgress] = useState({
message: 'Initializing...',
progress: 0,
})
const [error, setError] = useState<string | null>(null)
// LoadGames function outside of the useEffect to make it reusable
const loadGames = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
console.log('Invoking scan_steam_games')
const steamGames = await invoke<Game[]>('scan_steam_games')
// Add platform property to match GameList component's expectation
const gamesWithPlatform = steamGames.map((game) => ({
...game,
platform: 'Steam',
}))
console.log(`Loaded ${gamesWithPlatform.length} games`)
setGames(gamesWithPlatform)
setIsInitialLoad(false) // Mark initial load as complete
return true
} catch (error) {
console.error('Error loading games:', error)
setError(`Failed to load games: ${error}`)
setIsInitialLoad(false) // Mark initial load as complete even on error
return false
} finally {
setIsLoading(false)
}
}, [])
// Setup event listeners for game updates
useEffect(() => {
let unlisteners: (() => void)[] = []
// Set up event listeners
const setupEventListeners = async () => {
try {
console.log('Setting up game event listeners')
// Listen for individual game updates
const unlistenGameUpdated = await listen<Game>('game-updated', (event) => {
console.log('Received game-updated event:', event)
const updatedGame = event.payload
// Update only the specific game in the state
setGames((prevGames) =>
prevGames.map((game) =>
game.id === updatedGame.id
? { ...updatedGame, platform: 'Steam' }
: game
)
)
})
// Listen for scan progress events
const unlistenScanProgress = await listen<{
message: string;
progress: number;
}>('scan-progress', (event) => {
const { message, progress } = event.payload
console.log('Received scan-progress event:', message, progress)
// Update scan progress state
setScanProgress({
message,
progress,
})
})
unlisteners = [unlistenGameUpdated, unlistenScanProgress]
} catch (error) {
console.error('Error setting up event listeners:', error)
}
}
// Initialize event listeners and then load games
setupEventListeners().then(() => {
if (isInitialLoad) {
loadGames().catch(console.error)
}
})
// Cleanup function
return () => {
unlisteners.forEach(fn => fn())
}
}, [loadGames, isInitialLoad])
// Helper function to update a specific game in state
const updateGame = useCallback((updatedGame: Game) => {
setGames((prevGames) =>
prevGames.map((game) => (game.id === updatedGame.id ? updatedGame : game))
)
}, [])
return {
games,
isLoading,
isInitialLoad,
scanProgress,
error,
loadGames,
updateGame,
setGames,
}
}

94
src/hooks/useToasts.ts Normal file
View File

@@ -0,0 +1,94 @@
import { useState, useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
/**
* Toast type definition
*/
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/**
* Toast interface
*/
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
title?: string;
}
/**
* Toast options interface
*/
export interface ToastOptions {
title?: string;
duration?: number;
}
/**
* Hook for managing toast notifications
* Provides methods for adding and removing notifications of different types
*/
export function useToasts() {
const [toasts, setToasts] = useState<Toast[]>([])
/**
* Removes a toast by ID
*/
const removeToast = useCallback((id: string) => {
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id))
}, [])
/**
* Adds a new toast with the specified type and options
*/
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = uuidv4()
const newToast = { ...toast, id }
setToasts(currentToasts => [...currentToasts, newToast])
// Auto-remove toast after its duration expires
if (toast.duration !== Infinity) {
setTimeout(() => {
removeToast(id)
}, toast.duration || 5000) // Default 5 seconds
}
return id
}, [removeToast])
/**
* Shorthand method for success toasts
*/
const success = useCallback((message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'success', ...options }), [addToast])
/**
* Shorthand method for error toasts
*/
const error = useCallback((message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'error', ...options }), [addToast])
/**
* Shorthand method for warning toasts
*/
const warning = useCallback((message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'warning', ...options }), [addToast])
/**
* Shorthand method for info toasts
*/
const info = useCallback((message: string, options: ToastOptions = {}) =>
addToast({ message, type: 'info', ...options }), [addToast])
return {
toasts,
addToast,
removeToast,
success,
error,
warning,
info,
}
}