From 0be15f83e72838b153ced7e2edee827077011441 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Sun, 18 May 2025 08:06:56 +0200 Subject: [PATCH] Initial changes --- package-lock.json | 16 +- package.json | 3 +- src-tauri/src/cache.rs | 189 +--- src-tauri/src/main.rs | 787 +++++++-------- src/App.tsx | 938 ++---------------- src/components/ActionButton.tsx | 41 - src/components/DlcSelectionDialog.tsx | 239 ----- src/components/ImagePreloader.tsx | 41 - src/components/Sidebar.tsx | 33 - src/components/buttons/ActionButton.tsx | 59 ++ .../{ => buttons}/AnimatedCheckbox.tsx | 32 +- src/components/buttons/Button.tsx | 67 ++ src/components/buttons/index.ts | 8 + src/components/common/LoadingIndicator.tsx | 75 ++ src/components/common/index.ts | 3 + src/components/dialogs/Dialog.tsx | 82 ++ src/components/dialogs/DialogActions.tsx | 31 + src/components/dialogs/DialogBody.tsx | 20 + src/components/dialogs/DialogFooter.tsx | 20 + src/components/dialogs/DialogHeader.tsx | 30 + src/components/dialogs/DlcSelectionDialog.tsx | 221 +++++ .../{ => dialogs}/ProgressDialog.tsx | 149 ++- src/components/dialogs/index.ts | 17 + src/components/{ => games}/GameItem.tsx | 72 +- src/components/{ => games}/GameList.tsx | 50 +- src/components/games/ImagePreloader.tsx | 61 ++ src/components/games/index.ts | 4 + .../{ => layout}/AnimatedBackground.tsx | 25 +- src/components/layout/ErrorBoundary.tsx | 81 ++ src/components/{ => layout}/Header.tsx | 21 +- .../{ => layout}/InitialLoadingScreen.tsx | 33 +- src/components/layout/Sidebar.tsx | 36 + src/components/layout/index.ts | 6 + src/components/notifications/Toast.tsx | 83 ++ .../notifications/ToastContainer.tsx | 47 + src/components/notifications/index.ts | 5 + src/contexts/AppContext.tsx | 56 ++ src/contexts/AppProvider.tsx | 171 ++++ src/contexts/index.ts | 3 + src/contexts/useAppContext.ts | 16 + src/hooks/index.ts | 10 + src/hooks/useAppLogic.ts | 119 +++ src/hooks/useDlcManager.ts | 295 ++++++ src/hooks/useGameActions.ts | 221 +++++ src/hooks/useGames.ts | 126 +++ src/hooks/useToasts.ts | 94 ++ src/main.tsx | 7 +- src/services/ImageService.ts | 23 +- src/services/index.ts | 1 + src/styles/_fonts.scss | 7 +- src/styles/_layout.scss | 304 ++---- src/styles/_mixins.scss | 6 +- src/styles/_reset.scss | 6 +- src/styles/_variables.scss | 53 +- src/styles/components/_dialog.scss | 258 ----- src/styles/components/_dlc_dialog.scss | 331 ------ src/styles/components/_header.scss | 80 -- src/styles/components/_sidebar.scss | 208 ---- .../components/buttons/_action_button.scss | 103 ++ .../{ => buttons}/_animated_checkbox.scss | 6 +- src/styles/components/buttons/_button.scss | 159 +++ src/styles/components/common/_loading.scss | 167 ++++ src/styles/components/dialogs/_dialog.scss | 192 ++++ .../components/dialogs/_dlc_dialog.scss | 192 ++++ .../components/dialogs/_progress_dialog.scss | 114 +++ .../components/{ => games}/_gamecard.scss | 123 +-- src/styles/components/games/_gamelist.scss | 150 +++ .../components/{ => layout}/_background.scss | 7 +- src/styles/components/layout/_header.scss | 78 ++ .../{ => layout}/_loading_screen.scss | 3 + src/styles/components/layout/_sidebar.scss | 137 +++ .../components/notifications/_toast.scss | 171 ++++ src/styles/main.scss | 50 +- src/styles/pages/_home.scss | 27 + src/styles/themes/_dark.scss | 53 + src/types/DlcInfo.ts | 8 + src/types/Game.ts | 14 + src/types/index.ts | 2 + src/utils/helpers.ts | 85 ++ src/utils/index.ts | 1 + tsconfig.app.json | 4 + vite.config.ts | 7 + 82 files changed, 4636 insertions(+), 3237 deletions(-) delete mode 100644 src/components/ActionButton.tsx delete mode 100644 src/components/DlcSelectionDialog.tsx delete mode 100644 src/components/ImagePreloader.tsx delete mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/buttons/ActionButton.tsx rename src/components/{ => buttons}/AnimatedCheckbox.tsx (61%) create mode 100644 src/components/buttons/Button.tsx create mode 100644 src/components/buttons/index.ts create mode 100644 src/components/common/LoadingIndicator.tsx create mode 100644 src/components/common/index.ts create mode 100644 src/components/dialogs/Dialog.tsx create mode 100644 src/components/dialogs/DialogActions.tsx create mode 100644 src/components/dialogs/DialogBody.tsx create mode 100644 src/components/dialogs/DialogFooter.tsx create mode 100644 src/components/dialogs/DialogHeader.tsx create mode 100644 src/components/dialogs/DlcSelectionDialog.tsx rename src/components/{ => dialogs}/ProgressDialog.tsx (56%) create mode 100644 src/components/dialogs/index.ts rename src/components/{ => games}/GameItem.tsx (75%) rename src/components/{ => games}/GameList.tsx (59%) create mode 100644 src/components/games/ImagePreloader.tsx create mode 100644 src/components/games/index.ts rename src/components/{ => layout}/AnimatedBackground.tsx (88%) create mode 100644 src/components/layout/ErrorBoundary.tsx rename src/components/{ => layout}/Header.tsx (60%) rename src/components/{ => layout}/InitialLoadingScreen.tsx (53%) create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/layout/index.ts create mode 100644 src/components/notifications/Toast.tsx create mode 100644 src/components/notifications/ToastContainer.tsx create mode 100644 src/components/notifications/index.ts create mode 100644 src/contexts/AppContext.tsx create mode 100644 src/contexts/AppProvider.tsx create mode 100644 src/contexts/index.ts create mode 100644 src/contexts/useAppContext.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useAppLogic.ts create mode 100644 src/hooks/useDlcManager.ts create mode 100644 src/hooks/useGameActions.ts create mode 100644 src/hooks/useGames.ts create mode 100644 src/hooks/useToasts.ts create mode 100644 src/services/index.ts delete mode 100644 src/styles/components/_dialog.scss delete mode 100644 src/styles/components/_dlc_dialog.scss delete mode 100644 src/styles/components/_header.scss delete mode 100644 src/styles/components/_sidebar.scss create mode 100644 src/styles/components/buttons/_action_button.scss rename src/styles/components/{ => buttons}/_animated_checkbox.scss (97%) create mode 100644 src/styles/components/buttons/_button.scss create mode 100644 src/styles/components/common/_loading.scss create mode 100644 src/styles/components/dialogs/_dialog.scss create mode 100644 src/styles/components/dialogs/_dlc_dialog.scss create mode 100644 src/styles/components/dialogs/_progress_dialog.scss rename src/styles/components/{ => games}/_gamecard.scss (61%) create mode 100644 src/styles/components/games/_gamelist.scss rename src/styles/components/{ => layout}/_background.scss (69%) create mode 100644 src/styles/components/layout/_header.scss rename src/styles/components/{ => layout}/_loading_screen.scss (98%) create mode 100644 src/styles/components/layout/_sidebar.scss create mode 100644 src/styles/components/notifications/_toast.scss create mode 100644 src/styles/pages/_home.scss create mode 100644 src/styles/themes/_dark.scss create mode 100644 src/types/DlcInfo.ts create mode 100644 src/types/Game.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/helpers.ts create mode 100644 src/utils/index.ts diff --git a/package-lock.json b/package-lock.json index 2d46b39..4cb1d76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@tauri-apps/api": "^2.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "sass": "^1.89.0" + "sass": "^1.89.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -4247,6 +4248,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/package.json b/package.json index 1c6e8f5..6523a5b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "@tauri-apps/api": "^2.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "sass": "^1.89.0" + "sass": "^1.89.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs index 1a9ef37..b8d2815 100644 --- a/src-tauri/src/cache.rs +++ b/src-tauri/src/cache.rs @@ -1,178 +1,21 @@ -use crate::dlc_manager::DlcInfoWithState; -use log::{info, warn}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::fs; -use std::io; -use std::path::PathBuf; -use std::time::SystemTime; +// This is a placeholder file - cache functionality has been removed +// and now only exists in memory within the App state -// Cache entry with timestamp for expiration -#[derive(Serialize, Deserialize)] -struct CacheEntry { - data: T, - timestamp: u64, // Unix timestamp in seconds +pub fn cache_dlcs(_game_id: &str, _dlcs: &[crate::dlc_manager::DlcInfoWithState]) -> std::io::Result<()> { + // This function is kept only for compatibility, but now does nothing + // The DLCs are only cached in memory + log::info!("Cache functionality has been removed - DLCs are only stored in memory"); + Ok(()) } -// Get the cache directory -fn get_cache_dir() -> io::Result { - let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux") - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - let cache_dir = xdg_dirs.get_cache_home(); - - // Make sure the cache directory exists - if !cache_dir.exists() { - fs::create_dir_all(&cache_dir)?; - } - - Ok(cache_dir) +pub fn load_cached_dlcs(_game_id: &str) -> Option> { + // This function is kept only for compatibility, but now always returns None + log::info!("Cache functionality has been removed - DLCs are only stored in memory"); + None } -// Save data to cache file -pub fn save_to_cache(key: &str, data: &T, _ttl_hours: u64) -> io::Result<()> -where - T: Serialize + ?Sized, -{ - let cache_dir = get_cache_dir()?; - let cache_file = cache_dir.join(format!("{}.cache", key)); - - // Get current timestamp - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Create a JSON object with timestamp and data directly - let json_data = json!({ - "timestamp": now, - "data": data // No clone needed here - }); - - // Serialize and write to file - let serialized = - serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - fs::write(cache_file, serialized)?; - info!("Saved cache for key: {}", key); - - Ok(()) -} - -// Load data from cache file if it exists and is not expired -pub fn load_from_cache(key: &str, ttl_hours: u64) -> Option -where - T: for<'de> Deserialize<'de>, -{ - let cache_dir = match get_cache_dir() { - Ok(dir) => dir, - Err(e) => { - warn!("Failed to get cache directory: {}", e); - return None; - } - }; - - let cache_file = cache_dir.join(format!("{}.cache", key)); - - // Check if cache file exists - if !cache_file.exists() { - return None; - } - - // Read and deserialize - let cached_data = match fs::read_to_string(&cache_file) { - Ok(data) => data, - Err(e) => { - warn!("Failed to read cache file {}: {}", cache_file.display(), e); - return None; - } - }; - - // Parse the JSON - let json_value: serde_json::Value = match serde_json::from_str(&cached_data) { - Ok(v) => v, - Err(e) => { - warn!("Failed to parse cache file {}: {}", cache_file.display(), e); - return None; - } - }; - - // Extract timestamp - let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) { - Some(ts) => ts, - None => { - warn!("Invalid timestamp in cache file {}", cache_file.display()); - return None; - } - }; - - // Check expiration - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let age_hours = (now - timestamp) / 3600; - - if age_hours > ttl_hours { - info!("Cache for key {} is expired ({} hours old)", key, age_hours); - return None; - } - - // Extract data - let data: T = match serde_json::from_value(json_value["data"].clone()) { - Ok(d) => d, - Err(e) => { - warn!( - "Failed to parse data in cache file {}: {}", - cache_file.display(), - e - ); - return None; - } - }; - - info!("Using cache for key {} ({} hours old)", key, age_hours); - Some(data) -} - -// Cache game scanning results -pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> { - save_to_cache("games", games, 24) // Cache games for 24 hours -} - -// Load cached game scanning results -pub fn load_cached_games() -> Option> { - load_from_cache("games", 24) -} - -// Cache DLC list for a game -pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> { - save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours) -} - -// Load cached DLC list -pub fn load_cached_dlcs(game_id: &str) -> Option> { - load_from_cache(&format!("dlc_{}", game_id), 168) -} - -// Clear all caches -pub fn clear_all_caches() -> io::Result<()> { - let cache_dir = get_cache_dir()?; - - for entry in fs::read_dir(cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") { - if let Err(e) = fs::remove_file(&path) { - warn!("Failed to remove cache file {}: {}", path.display(), e); - } else { - info!("Removed cache file: {}", path.display()); - } - } - } - - info!("All caches cleared"); - Ok(()) -} +pub fn clear_all_caches() -> std::io::Result<()> { + // This function is kept only for compatibility, but now does nothing + log::info!("Cache functionality has been removed - DLCs are only stored in memory"); + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3760d2d..e3cb4bb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,11 @@ #![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" )] -mod cache; mod dlc_manager; mod installer; -mod searcher; // Keep the module for now +mod searcher; use dlc_manager::DlcInfoWithState; use installer::{Game, InstallerAction, InstallerType}; @@ -19,529 +18,531 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use tauri::State; use tauri::{Emitter, Manager}; -use tokio::time::Duration; use tokio::time::Instant; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GameAction { - game_id: String, - action: String, + game_id: String, + action: String, } +// Mark fields with # to allow unused fields #[derive(Debug, Clone)] struct DlcCache { - data: Vec, - timestamp: Instant, + #[allow(dead_code)] + data: Vec, + #[allow(dead_code)] + timestamp: Instant, } // Structure to hold the state of installed games struct AppState { - games: Mutex>, - dlc_cache: Mutex>, - fetch_cancellation: Arc, + games: Mutex>, + dlc_cache: Mutex>, + fetch_cancellation: Arc, } #[tauri::command] fn get_all_dlcs_command(game_path: String) -> Result, String> { - info!("Getting all DLCs (enabled and disabled) for: {}", game_path); - dlc_manager::get_all_dlcs(&game_path) + info!("Getting all DLCs (enabled and disabled) for: {}", game_path); + dlc_manager::get_all_dlcs(&game_path) } // Scan and get the list of Steam games #[tauri::command] async fn scan_steam_games( - state: State<'_, AppState>, - app_handle: tauri::AppHandle, + state: State<'_, AppState>, + app_handle: tauri::AppHandle, ) -> Result, String> { - info!("Starting Steam games scan"); - emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); + info!("Starting Steam games scan"); + emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); - // Get default Steam paths - let paths = searcher::get_default_steam_paths(); + // Get default Steam paths + let paths = searcher::get_default_steam_paths(); - // Find Steam libraries - emit_scan_progress(&app_handle, "Finding Steam libraries...", 15); - let libraries = searcher::find_steam_libraries(&paths); + // Find Steam libraries + emit_scan_progress(&app_handle, "Finding Steam libraries...", 15); + let libraries = searcher::find_steam_libraries(&paths); - // Group libraries by path to avoid duplicates in logs - let mut unique_libraries = std::collections::HashSet::new(); - for lib in &libraries { - unique_libraries.insert(lib.to_string_lossy().to_string()); - } + // Group libraries by path to avoid duplicates in logs + let mut unique_libraries = std::collections::HashSet::new(); + for lib in &libraries { + unique_libraries.insert(lib.to_string_lossy().to_string()); + } - info!( - "Found {} Steam library directories:", - unique_libraries.len() - ); - for (i, lib) in unique_libraries.iter().enumerate() { - info!(" Library {}: {}", i + 1, lib); - } + info!( + "Found {} Steam library directories:", + unique_libraries.len() + ); + for (i, lib) in unique_libraries.iter().enumerate() { + info!(" Library {}: {}", i + 1, lib); + } - emit_scan_progress( - &app_handle, - &format!( - "Found {} Steam libraries. Starting game scan...", - unique_libraries.len() - ), - 20, - ); + emit_scan_progress( + &app_handle, + &format!( + "Found {} Steam libraries. Starting game scan...", + unique_libraries.len() + ), + 20, + ); - // Find installed games - let games_info = searcher::find_installed_games(&libraries).await; + // Find installed games + let games_info = searcher::find_installed_games(&libraries).await; - emit_scan_progress( - &app_handle, - &format!("Found {} games. Processing...", games_info.len()), - 90, - ); + emit_scan_progress( + &app_handle, + &format!("Found {} games. Processing...", games_info.len()), + 90, + ); - // Log summary of games found - info!("Games scan complete - Found {} games", games_info.len()); - info!( - "Native games: {}", - games_info.iter().filter(|g| g.native).count() - ); - info!( - "Proton games: {}", - games_info.iter().filter(|g| !g.native).count() - ); - info!( - "Games with CreamLinux: {}", - games_info.iter().filter(|g| g.cream_installed).count() - ); - info!( - "Games with SmokeAPI: {}", - games_info.iter().filter(|g| g.smoke_installed).count() - ); + // Log summary of games found + info!("Games scan complete - Found {} games", games_info.len()); + info!( + "Native games: {}", + games_info.iter().filter(|g| g.native).count() + ); + info!( + "Proton games: {}", + games_info.iter().filter(|g| !g.native).count() + ); + info!( + "Games with CreamLinux: {}", + games_info.iter().filter(|g| g.cream_installed).count() + ); + info!( + "Games with SmokeAPI: {}", + games_info.iter().filter(|g| g.smoke_installed).count() + ); - // Convert to our Game struct - let mut result = Vec::new(); + // Convert to our Game struct + let mut result = Vec::new(); - info!("Processing games into application state..."); - for game_info in games_info { - // Only log detailed game info at Debug level to keep Info logs cleaner - debug!( - "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", - game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed - ); + info!("Processing games into application state..."); + for game_info in games_info { + // Only log detailed game info at Debug level to keep Info logs cleaner + debug!( + "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", + game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed + ); - let game = Game { - id: game_info.id, - title: game_info.title, - path: game_info.path.to_string_lossy().to_string(), - native: game_info.native, - api_files: game_info.api_files, - cream_installed: game_info.cream_installed, - smoke_installed: game_info.smoke_installed, - installing: false, - }; + let game = Game { + id: game_info.id, + title: game_info.title, + path: game_info.path.to_string_lossy().to_string(), + native: game_info.native, + api_files: game_info.api_files, + cream_installed: game_info.cream_installed, + smoke_installed: game_info.smoke_installed, + installing: false, + }; - result.push(game.clone()); + result.push(game.clone()); - // Store in state for later use - state.games.lock().insert(game.id.clone(), game); - } + // Store in state for later use + state.games.lock().insert(game.id.clone(), game); + } - emit_scan_progress( - &app_handle, - &format!("Scan complete. Found {} games.", result.len()), - 100, - ); + emit_scan_progress( + &app_handle, + &format!("Scan complete. Found {} games.", result.len()), + 100, + ); - info!("Game scan completed successfully"); - Ok(result) + info!("Game scan completed successfully"); + Ok(result) } // Helper function to emit scan progress events fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) { - // Log first, then emit the event - info!("Scan progress: {}% - {}", progress, message); + // Log first, then emit the event + info!("Scan progress: {}% - {}", progress, message); - let payload = serde_json::json!({ - "message": message, - "progress": progress - }); + let payload = serde_json::json!({ + "message": message, + "progress": progress + }); - if let Err(e) = app_handle.emit("scan-progress", payload) { - warn!("Failed to emit scan-progress event: {}", e); - } + if let Err(e) = app_handle.emit("scan-progress", payload) { + warn!("Failed to emit scan-progress event: {}", e); + } } // Fetch game info by ID - useful for single game updates #[tauri::command] fn get_game_info(game_id: String, state: State) -> Result { - let games = state.games.lock(); - games - .get(&game_id) - .cloned() - .ok_or_else(|| format!("Game with ID {} not found", game_id)) + let games = state.games.lock(); + games + .get(&game_id) + .cloned() + .ok_or_else(|| format!("Game with ID {} not found", game_id)) } // Unified action handler for installation and uninstallation #[tauri::command] async fn process_game_action( - game_action: GameAction, - state: State<'_, AppState>, - app_handle: tauri::AppHandle, + game_action: GameAction, + state: State<'_, AppState>, + app_handle: tauri::AppHandle, ) -> Result { - // Clone the information we need from state to avoid lifetime issues - let game = { - let games = state.games.lock(); - games - .get(&game_action.game_id) - .cloned() - .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? - }; + // Clone the information we need from state to avoid lifetime issues + let game = { + let games = state.games.lock(); + games + .get(&game_action.game_id) + .cloned() + .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? + }; - // Parse the action string to determine type and operation - let (installer_type, action) = match game_action.action.as_str() { - "install_cream" => (InstallerType::Cream, InstallerAction::Install), - "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), - "install_smoke" => (InstallerType::Smoke, InstallerAction::Install), - "uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall), - _ => return Err(format!("Invalid action: {}", game_action.action)), - }; + // Parse the action string to determine type and operation + let (installer_type, action) = match game_action.action.as_str() { + "install_cream" => (InstallerType::Cream, InstallerAction::Install), + "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), + "install_smoke" => (InstallerType::Smoke, InstallerAction::Install), + "uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall), + _ => return Err(format!("Invalid action: {}", game_action.action)), + }; - // Execute the action - installer::process_action( - game_action.game_id.clone(), - installer_type, - action, - game.clone(), - app_handle.clone(), - ) - .await?; + // Execute the action + installer::process_action( + game_action.game_id.clone(), + installer_type, + action, + game.clone(), + app_handle.clone(), + ) + .await?; - // Update game status in state based on the action - let updated_game = { - let mut games_map = state.games.lock(); - let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| { - format!( - "Game with ID {} not found after action", - game_action.game_id - ) - })?; + // Update game status in state based on the action + let updated_game = { + let mut games_map = state.games.lock(); + let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| { + format!( + "Game with ID {} not found after action", + game_action.game_id + ) + })?; - // Update installation status - match (installer_type, action) { - (InstallerType::Cream, InstallerAction::Install) => { - game.cream_installed = true; - } - (InstallerType::Cream, InstallerAction::Uninstall) => { - game.cream_installed = false; - } - (InstallerType::Smoke, InstallerAction::Install) => { - game.smoke_installed = true; - } - (InstallerType::Smoke, InstallerAction::Uninstall) => { - game.smoke_installed = false; - } - } + // Update installation status + match (installer_type, action) { + (InstallerType::Cream, InstallerAction::Install) => { + game.cream_installed = true; + } + (InstallerType::Cream, InstallerAction::Uninstall) => { + game.cream_installed = false; + } + (InstallerType::Smoke, InstallerAction::Install) => { + game.smoke_installed = true; + } + (InstallerType::Smoke, InstallerAction::Uninstall) => { + game.smoke_installed = false; + } + } - // Reset installing flag - game.installing = false; + // Reset installing flag + game.installing = false; - // Return updated game info - game.clone() - }; + // Return updated game info + game.clone() + }; - // Emit an event to update the UI for this specific game - if let Err(e) = app_handle.emit("game-updated", &updated_game) { - warn!("Failed to emit game-updated event: {}", e); - } + // Emit an event to update the UI for this specific game + if let Err(e) = app_handle.emit("game-updated", &updated_game) { + warn!("Failed to emit game-updated event: {}", e); + } - Ok(updated_game) + Ok(updated_game) } // Fetch DLC list for a game #[tauri::command] async fn fetch_game_dlcs( - game_id: String, - app_handle: tauri::AppHandle, + game_id: String, + app_handle: tauri::AppHandle, ) -> Result, String> { - info!("Fetching DLCs for game ID: {}", game_id); + info!("Fetching DLCs for game ID: {}", game_id); - // Fetch DLC data - match installer::fetch_dlc_details(&game_id).await { - Ok(dlcs) => { - // Convert to DlcInfoWithState - let dlcs_with_state = dlcs - .into_iter() - .map(|dlc| DlcInfoWithState { - appid: dlc.appid, - name: dlc.name, - enabled: true, - }) - .collect::>(); + // Fetch DLC data + match installer::fetch_dlc_details(&game_id).await { + Ok(dlcs) => { + // Convert to DlcInfoWithState + let dlcs_with_state = dlcs + .into_iter() + .map(|dlc| DlcInfoWithState { + appid: dlc.appid, + name: dlc.name, + enabled: true, + }) + .collect::>(); - // Cache in memory for this session (but not on disk) - let state = app_handle.state::(); - let mut cache = state.dlc_cache.lock(); - cache.insert( - game_id.clone(), - DlcCache { - data: dlcs_with_state.clone(), - timestamp: Instant::now(), - }, - ); + // Cache in memory for this session (but not on disk) + let state = app_handle.state::(); + let mut cache = state.dlc_cache.lock(); + cache.insert( + game_id.clone(), + DlcCache { + data: dlcs_with_state.clone(), + timestamp: Instant::now(), + }, + ); - Ok(dlcs_with_state) - } - Err(e) => Err(format!("Failed to fetch DLC details: {}", e)), - } + Ok(dlcs_with_state) + } + Err(e) => Err(format!("Failed to fetch DLC details: {}", e)), + } } #[tauri::command] fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { - info!("Request to abort DLC fetch for game ID: {}", game_id); + info!("Request to abort DLC fetch for game ID: {}", game_id); - let state = app_handle.state::(); - state.fetch_cancellation.store(true, Ordering::SeqCst); + let state = app_handle.state::(); + state.fetch_cancellation.store(true, Ordering::SeqCst); - // Reset after a short delay - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(500)); - let state = app_handle.state::(); - state.fetch_cancellation.store(false, Ordering::SeqCst); - }); + // Reset after a short delay + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let state = app_handle.state::(); + state.fetch_cancellation.store(false, Ordering::SeqCst); + }); - Ok(()) + Ok(()) } // Fetch DLC list with progress updates (streaming) #[tauri::command] async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { - info!("Streaming DLCs for game ID: {}", game_id); + info!("Streaming DLCs for game ID: {}", game_id); - // Fetch DLC data from API - match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { - Ok(dlcs) => { - info!( - "Successfully streamed {} DLCs for game {}", - dlcs.len(), - game_id - ); + // Fetch DLC data from API + match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { + Ok(dlcs) => { + info!( + "Successfully streamed {} DLCs for game {}", + dlcs.len(), + game_id + ); - // Convert to DLCInfoWithState for in-memory caching only - let dlcs_with_state = dlcs - .into_iter() - .map(|dlc| DlcInfoWithState { - appid: dlc.appid, - name: dlc.name, - enabled: true, - }) - .collect::>(); + // Convert to DLCInfoWithState for in-memory caching only + let dlcs_with_state = dlcs + .into_iter() + .map(|dlc| DlcInfoWithState { + appid: dlc.appid, + name: dlc.name, + enabled: true, + }) + .collect::>(); - // Update in-memory cache without storing to disk - let state = app_handle.state::(); - let mut dlc_cache = state.dlc_cache.lock(); - dlc_cache.insert( - game_id.clone(), - DlcCache { - data: dlcs_with_state, - timestamp: tokio::time::Instant::now(), - }, - ); + // Update in-memory cache without storing to disk + let state = app_handle.state::(); + let mut dlc_cache = state.dlc_cache.lock(); + dlc_cache.insert( + game_id.clone(), + DlcCache { + data: dlcs_with_state, + timestamp: tokio::time::Instant::now(), + }, + ); - Ok(()) - } - Err(e) => { - error!("Failed to stream DLC details: {}", e); - // Emit error event - let error_payload = serde_json::json!({ - "error": format!("Failed to fetch DLC details: {}", e) - }); + Ok(()) + } + Err(e) => { + error!("Failed to stream DLC details: {}", e); + // Emit error event + let error_payload = serde_json::json!({ + "error": format!("Failed to fetch DLC details: {}", e) + }); - if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { - warn!("Failed to emit dlc-error event: {}", emit_err); - } + if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { + warn!("Failed to emit dlc-error event: {}", emit_err); + } - Err(format!("Failed to fetch DLC details: {}", e)) - } - } + Err(format!("Failed to fetch DLC details: {}", e)) + } + } } // Clear caches command renamed to flush_data for clarity #[tauri::command] fn clear_caches() -> Result<(), String> { - info!("Data flush requested - cleaning in-memory state only"); - Ok(()) + info!("Data flush requested - cleaning in-memory state only"); + Ok(()) } // Get the list of enabled DLCs for a game #[tauri::command] fn get_enabled_dlcs_command(game_path: String) -> Result, String> { - info!("Getting enabled DLCs for: {}", game_path); - dlc_manager::get_enabled_dlcs(&game_path) + info!("Getting enabled DLCs for: {}", game_path); + dlc_manager::get_enabled_dlcs(&game_path) } // Update the DLC configuration for a game #[tauri::command] fn update_dlc_configuration_command( - game_path: String, - dlcs: Vec, + game_path: String, + dlcs: Vec, ) -> Result<(), String> { - info!("Updating DLC configuration for: {}", game_path); - dlc_manager::update_dlc_configuration(&game_path, dlcs) + info!("Updating DLC configuration for: {}", game_path); + dlc_manager::update_dlc_configuration(&game_path, dlcs) } // Install CreamLinux with selected DLCs #[tauri::command] async fn install_cream_with_dlcs_command( - game_id: String, - selected_dlcs: Vec, - app_handle: tauri::AppHandle, + game_id: String, + selected_dlcs: Vec, + app_handle: tauri::AppHandle, ) -> Result { - info!( - "Installing CreamLinux with selected DLCs for game: {}", - game_id - ); + info!( + "Installing CreamLinux with selected DLCs for game: {}", + game_id + ); - // Clone selected_dlcs for later use - let selected_dlcs_clone = selected_dlcs.clone(); + // Clone selected_dlcs for later use + let selected_dlcs_clone = selected_dlcs.clone(); - // Install CreamLinux with the selected DLCs - match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs) - .await - { - Ok(_) => { - // Return updated game info - let state = app_handle.state::(); + // Install CreamLinux with the selected DLCs + match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs) + .await + { + Ok(_) => { + // Return updated game info + let state = app_handle.state::(); - // Get a mutable reference and update the game - let game = { - let mut games_map = state.games.lock(); - let game = games_map.get_mut(&game_id).ok_or_else(|| { - format!("Game with ID {} not found after installation", game_id) - })?; + // Get a mutable reference and update the game + let game = { + let mut games_map = state.games.lock(); + let game = games_map.get_mut(&game_id).ok_or_else(|| { + format!("Game with ID {} not found after installation", game_id) + })?; - // Update installation status - game.cream_installed = true; - game.installing = false; + // Update installation status + game.cream_installed = true; + game.installing = false; - // Clone the game for returning later - game.clone() - }; + // Clone the game for returning later + game.clone() + }; - // Emit an event to update the UI - if let Err(e) = app_handle.emit("game-updated", &game) { - warn!("Failed to emit game-updated event: {}", e); - } + // Emit an event to update the UI + if let Err(e) = app_handle.emit("game-updated", &game) { + warn!("Failed to emit game-updated event: {}", e); + } - // Show installation complete dialog with instructions - let instructions = installer::InstallationInstructions { - type_: "cream_install".to_string(), - command: "sh ./cream.sh %command%".to_string(), - game_title: game.title.clone(), - dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()), - }; + // Show installation complete dialog with instructions + let instructions = installer::InstallationInstructions { + type_: "cream_install".to_string(), + command: "sh ./cream.sh %command%".to_string(), + game_title: game.title.clone(), + dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()), + }; - installer::emit_progress( - &app_handle, - &format!("Installation Completed: {}", game.title), - "CreamLinux has been installed successfully!", - 100.0, - true, - true, - Some(instructions), - ); + installer::emit_progress( + &app_handle, + &format!("Installation Completed: {}", game.title), + "CreamLinux has been installed successfully!", + 100.0, + true, + true, + Some(instructions), + ); - Ok(game) - } - Err(e) => { - error!("Failed to install CreamLinux with selected DLCs: {}", e); - Err(format!( - "Failed to install CreamLinux with selected DLCs: {}", - e - )) - } - } + Ok(game) + } + Err(e) => { + error!("Failed to install CreamLinux with selected DLCs: {}", e); + Err(format!( + "Failed to install CreamLinux with selected DLCs: {}", + e + )) + } + } } // Setup logging fn setup_logging() -> Result<(), Box> { - use log::LevelFilter; - use log4rs::append::file::FileAppender; - use log4rs::config::{Appender, Config, Root}; - use log4rs::encode::pattern::PatternEncoder; - use std::fs; + use log::LevelFilter; + use log4rs::append::file::FileAppender; + use log4rs::config::{Appender, Config, Root}; + use log4rs::encode::pattern::PatternEncoder; + use std::fs; - // Get XDG cache directory - let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; - let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; + // Get XDG cache directory + let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; + let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; - // Clear the log file on startup - if log_path.exists() { - if let Err(e) = fs::write(&log_path, "") { - eprintln!("Warning: Failed to clear log file: {}", e); - } - } + // Clear the log file on startup + if log_path.exists() { + if let Err(e) = fs::write(&log_path, "") { + eprintln!("Warning: Failed to clear log file: {}", e); + } + } - // Create a file appender - let file = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new( - "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n", - ))) - .build(log_path)?; + // Create a file appender + let file = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new( + "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n", + ))) + .build(log_path)?; - // Build the config - let config = Config::builder() - .appender(Appender::builder().build("file", Box::new(file))) - .build(Root::builder().appender("file").build(LevelFilter::Info))?; + // Build the config + let config = Config::builder() + .appender(Appender::builder().build("file", Box::new(file))) + .build(Root::builder().appender("file").build(LevelFilter::Info))?; - // Initialize log4rs with this config - log4rs::init_config(config)?; + // Initialize log4rs with this config + log4rs::init_config(config)?; - info!("CreamLinux started with a clean log file"); - Ok(()) + info!("CreamLinux started with a clean log file"); + Ok(()) } fn main() { - // Set up logging first - if let Err(e) = setup_logging() { - eprintln!("Warning: Failed to initialize logging: {}", e); - } + // Set up logging first + if let Err(e) = setup_logging() { + eprintln!("Warning: Failed to initialize logging: {}", e); + } - info!("Initializing CreamLinux application"); + info!("Initializing CreamLinux application"); - let app_state = AppState { - games: Mutex::new(HashMap::new()), - dlc_cache: Mutex::new(HashMap::new()), - fetch_cancellation: Arc::new(AtomicBool::new(false)), - }; + let app_state = AppState { + games: Mutex::new(HashMap::new()), + dlc_cache: Mutex::new(HashMap::new()), + fetch_cancellation: Arc::new(AtomicBool::new(false)), + }; - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .manage(app_state) - .invoke_handler(tauri::generate_handler![ - scan_steam_games, - get_game_info, - process_game_action, - fetch_game_dlcs, - stream_game_dlcs, - get_enabled_dlcs_command, - update_dlc_configuration_command, - install_cream_with_dlcs_command, - get_all_dlcs_command, - clear_caches, - abort_dlc_fetch, - ]) - .setup(|app| { - // Add a setup handler to do any initialization work - info!("Tauri application setup"); + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + scan_steam_games, + get_game_info, + process_game_action, + fetch_game_dlcs, + stream_game_dlcs, + get_enabled_dlcs_command, + update_dlc_configuration_command, + install_cream_with_dlcs_command, + get_all_dlcs_command, + clear_caches, + abort_dlc_fetch, + ]) + .setup(|app| { + // Add a setup handler to do any initialization work + info!("Tauri application setup"); - #[cfg(debug_assertions)] - { - if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { - if let Some(window) = app.get_webview_window("main") { - window.open_devtools(); - } - } - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} + #[cfg(debug_assertions)] + { + if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + } + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c550648..135a9ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,854 +1,114 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { listen } from '@tauri-apps/api/event' +import { useAppContext } from '@/contexts/useAppContext' +import { useAppLogic } from '@/hooks' import './styles/main.scss' -import GameList from './components/GameList' -import Header from './components/Header' -import Sidebar from './components/Sidebar' -import ProgressDialog from './components/ProgressDialog' -import DlcSelectionDialog from './components/DlcSelectionDialog' -import AnimatedBackground from './components/AnimatedBackground' -import InitialLoadingScreen from './components/InitialLoadingScreen' -import { ActionType } from './components/ActionButton' -// Game interface -interface Game { - id: string - title: string - path: string - native: boolean - platform?: string - api_files: string[] - cream_installed?: boolean - smoke_installed?: boolean - installing?: boolean -} +// Layout components +import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary } from '@/components/layout' +import AnimatedBackground from '@/components/layout/AnimatedBackground' -// Interface for installation instructions -interface InstructionInfo { - type: string - command: string - game_title: string - dlc_count?: number -} +// Dialog components +import { ProgressDialog, DlcSelectionDialog } from '@/components/dialogs' -// Interface for DLC information -interface DlcInfo { - appid: string - name: string - enabled: boolean -} +// Game components +import { GameList } from '@/components/games' +/** + * Main application component + */ function App() { - const [games, setGames] = useState([]) - const [filter, setFilter] = useState('all') - const [searchQuery, setSearchQuery] = useState('') - const [isLoading, setIsLoading] = useState(true) - const [isInitialLoad, setIsInitialLoad] = useState(true) - const [scanProgress, setScanProgress] = useState({ - message: 'Initializing...', - progress: 0, - }) - const [error, setError] = useState(null) - const refreshInProgress = useRef(false) - const [isFetchingDlcs, setIsFetchingDlcs] = useState(false) - const dlcFetchController = useRef(null) - const activeDlcFetchId = useRef(null) - - // Progress dialog state - const [progressDialog, setProgressDialog] = useState({ - visible: false, - title: '', - message: '', - progress: 0, - showInstructions: false, - instructions: undefined as InstructionInfo | undefined, - }) - - // DLC selection dialog state - const [dlcDialog, setDlcDialog] = useState({ - visible: false, - gameId: '', - gameTitle: '', - dlcs: [] as DlcInfo[], - enabledDlcs: [] as string[], - isLoading: false, - isEditMode: false, - progress: 0, - progressMessage: '', - timeLeft: '', - error: null as string | null, - }) - - // Handle search query changes - const handleSearchChange = (query: string) => { - setSearchQuery(query) - } - - // 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('scan_steam_games').catch((err) => { - console.error('Error from scan_steam_games:', err) - throw err - }) - - // Platform property to match the 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) - } - }, []) - - useEffect(() => { - // Set up event listeners first - const setupEventListeners = async () => { - try { - console.log('Setting up event listeners') - - // Listen for progress updates from the backend - const unlistenProgress = await listen('installation-progress', (event) => { - console.log('Received installation-progress event:', event) - - const { title, message, progress, complete, show_instructions, instructions } = - event.payload as { - title: string - message: string - progress: number - complete: boolean - show_instructions?: boolean - instructions?: InstructionInfo - } - - if (complete && !show_instructions) { - // Hide dialog when complete if no instructions - setTimeout(() => { - setProgressDialog((prev) => ({ ...prev, visible: false })) - - // Only refresh games list if dialog is closing without instructions - if (!refreshInProgress.current) { - refreshInProgress.current = true - setTimeout(() => { - loadGames().then(() => { - refreshInProgress.current = false - }) - }, 100) - } - }, 1000) - } else { - // Update progress dialog - setProgressDialog({ - visible: true, - title, - message, - progress, - showInstructions: show_instructions || false, - instructions, - }) - } - }) - - // Listen for scan progress events - const unlistenScanProgress = await listen('scan-progress', (event) => { - const { message, progress } = event.payload as { - message: string - progress: number - } - - console.log('Received scan-progress event:', message, progress) - - // Update scan progress state - setScanProgress({ - message, - progress, - }) - }) - - // Listen for individual game updates - const unlistenGameUpdated = await listen('game-updated', (event) => { - console.log('Received game-updated event:', event) - - const updatedGame = event.payload as Game - - // Update only the specific game in the state - setGames((prevGames) => - prevGames.map((game) => - game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game - ) - ) - }) - - return () => { - unlistenProgress() - unlistenScanProgress() - unlistenGameUpdated() - } - } catch (error) { - console.error('Error setting up event listeners:', error) - return () => {} - } - } - - // First set up event listeners, then load games - let unlisten: (() => void) | null = null - - setupEventListeners() - .then((unlistenFn) => { - unlisten = unlistenFn - return loadGames() - }) - .catch((error) => { - console.error('Failed to initialize:', error) - }) - - return () => { - if (unlisten) { - unlisten() - } - } - }, [loadGames]) - - // Debugging for state changes - useEffect(() => { - // Debug state changes - if (games.length > 0) { - // Count native and installed games - const nativeCount = games.filter((g) => g.native).length - const creamInstalledCount = games.filter((g) => g.cream_installed).length - const smokeInstalledCount = games.filter((g) => g.smoke_installed).length - - console.log( - `Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI` - ) - - // Log any games with unexpected states - const problematicGames = games.filter((g) => { - // Native games that have SmokeAPI installed (shouldn't happen) - if (g.native && g.smoke_installed) return true - - // Non-native games with CreamLinux installed (shouldn't happen) - if (!g.native && g.cream_installed) return true - - // Non-native games without API files but with SmokeAPI installed (shouldn't happen) - if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed) - return true - - return false - }) - - if (problematicGames.length > 0) { - console.warn('Found games with unexpected states:', problematicGames) - } - } - }, [games]) - - // 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('dlc-found', (event) => { - const dlc = JSON.parse(event.payload as string) 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('dlc-progress', (event) => { - const { message, progress, timeLeft } = event.payload as { - message: string - progress: number - timeLeft?: string - } - - // 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('dlc-error', (event) => { - const { error } = event.payload as { error: string } - 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 unlisten = setupDlcEventListeners() - return () => { - unlisten.then((fn) => fn()) - } - }, []) - - // Listen for scan progress events - useEffect(() => { - const listenToScanProgress = async () => { - try { - const unlistenScanProgress = await listen('scan-progress', (event) => { - const { message, progress } = event.payload as { - message: string - progress: number - } - - // Update loading message - setProgressDialog((prev) => ({ - ...prev, - visible: true, - title: 'Scanning for Games', - message, - progress, - showInstructions: false, - instructions: undefined, - })) - - // Auto-close when complete - if (progress >= 100) { - setTimeout(() => { - setProgressDialog((prev) => ({ ...prev, visible: false })) - }, 1500) - } - }) - - return unlistenScanProgress - } catch (error) { - console.error('Error setting up scan progress listener:', error) - return () => {} - } - } - - const unlistenPromise = listenToScanProgress() - return () => { - unlistenPromise.then((unlisten) => unlisten()) - } - }, []) - - const handleCloseProgressDialog = () => { - // Just hide the dialog without refreshing game list - setProgressDialog((prev) => ({ ...prev, visible: false })) - - // Only refresh if we need to (instructions didn't trigger update) - if (progressDialog.showInstructions === false && !refreshInProgress.current) { - refreshInProgress.current = true - setTimeout(() => { - loadGames().then(() => { - refreshInProgress.current = false - }) - }, 100) - } - } - - // Function to fetch DLCs for a game with streaming updates - const streamGameDlcs = async (gameId: string): Promise => { - 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 - } - } - - // 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 - } - } - }, []) - - // Handle game edit (show DLC management dialog) - const handleGameEdit = async (gameId: string) => { - 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: [] as string[], - 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('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('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, - })) - } - } - - // Unified handler for all game actions (install/uninstall cream/smoke) - const handleGameAction = async (gameId: string, action: ActionType) => { - try { - // Find game to get title - const game = games.find((g) => g.id === gameId) - if (!game) return - - // If we're installing CreamLinux, show DLC selection first - if (action === 'install_cream') { - try { - // Show dialog immediately with empty DLC list and loading state - setDlcDialog({ - visible: true, - gameId, - gameTitle: game.title, - dlcs: [], // Start with an empty array - enabledDlcs: [] as string[], - isLoading: true, - isEditMode: false, - progress: 0, - progressMessage: 'Fetching DLC list...', - timeLeft: '', - error: null, - }) - - // Start streaming DLCs - only once - await streamGameDlcs(gameId).catch((error) => { - console.error('Error streaming DLCs:', error) - setDlcDialog((prev) => ({ - ...prev, - error: `Failed to load DLCs: ${error}`, - isLoading: false, - })) - }) - } catch (error) { - console.error('Error fetching DLCs:', error) - - // If DLC fetching fails, close dialog and show error - setDlcDialog((prev) => ({ - ...prev, - visible: false, - isLoading: false, - })) - - setProgressDialog({ - visible: true, - title: `Error fetching DLCs for ${game.title}`, - message: `Failed to fetch DLCs: ${error}`, - progress: 100, - showInstructions: false, - instructions: undefined, - }) - - setTimeout(() => { - setProgressDialog((prev) => ({ ...prev, visible: false })) - }, 3000) - } - return - } - - // For other actions, proceed directly - // Update local state to show installation in progress - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) - ) - - // 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 - const updatedGame = await invoke('process_game_action', { - gameAction: { - game_id: gameId, - action, - }, - }).catch((err) => { - console.error(`Error from process_game_action:`, err) - throw err - }) - - console.log('Game action completed, updated game:', updatedGame) - - // Update our local state with the result from the backend - if (updatedGame) { - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) - ) - } - } 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, - })) - - // Reset installing state - setGames((prevGames) => - prevGames.map((game) => (game.id === gameId ? { ...game, installing: false } : game)) - ) - - // Hide dialog after a delay - setTimeout(() => { - setProgressDialog((prev) => ({ ...prev, visible: false })) - }, 3000) - } - } - - // 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 })) - } - - // Handle DLC selection confirmation - const handleDlcConfirm = async (selectedDlcs: DlcInfo[]) => { - // Close the dialog first - setDlcDialog((prev) => ({ ...prev, visible: false })) - - const gameId = dlcDialog.gameId - const game = games.find((g) => g.id === gameId) - if (!game) return - - // Update local state to show installation in progress - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) - ) - - try { - if (dlcDialog.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 })) - // Reset installing state - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) - ) - }, 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((err) => { - console.error(`Error installing CreamLinux with selected DLCs:`, err) - throw err - }) - - // We don't need to manually close the dialog or update the game state - // because the backend will emit progress events that handle this - } - } catch (error) { - console.error('Error processing DLC selection:', error) - - // Show error in progress dialog - setProgressDialog((prev) => ({ - ...prev, - message: `Error: ${error}`, - progress: 100, - })) - - // Reset installing state - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) - ) - - // Hide dialog after a delay - setTimeout(() => { - setProgressDialog((prev) => ({ ...prev, visible: false })) - }, 3000) - } - } - - // 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]) - - // Filter games based on sidebar filter and search query - const filteredGames = games.filter((game) => { - // First filter by the platform/type - const platformMatch = - filter === 'all' || - (filter === 'native' && game.native) || - (filter === 'proton' && !game.native) - - // Then filter by search query (if any) - const searchMatch = - searchQuery.trim() === '' || game.title.toLowerCase().includes(searchQuery.toLowerCase()) - - // Both filters must match - return platformMatch && searchMatch - }) - - // Check if we should show the initial loading screen + // Get application logic from hook + const { + filter, + setFilter, + searchQuery, + handleSearchChange, + isInitialLoad, + scanProgress, + filteredGames, + handleRefresh, + isLoading, + error + } = useAppLogic({ autoLoad: true }) + + // Get action handlers from context + const { + dlcDialog, + handleDlcDialogClose, + progressDialog, + handleGameAction, + handleDlcConfirm, + handleGameEdit + } = useAppContext() + + // Show loading screen during initial load if (isInitialLoad) { - return + return } return ( -
- {/* Animated background */} - + +
+ {/* Animated background */} + -
-
- - {error ? ( -
-

Error Loading Games

-

{error}

- -
- ) : ( - - )} + {/* Header with search */} +
+ +
+ {/* Sidebar for filtering */} + + + {/* Show error or game list */} + {error ? ( +
+

Error Loading Games

+

{error}

+ +
+ ) : ( + + )} +
+ + {/* Progress Dialog */} + {}} + /> + + {/* DLC Selection Dialog */} +
- - {/* Progress Dialog */} - - - {/* DLC Selection Dialog */} - -
+
) } -export default App +export default App \ No newline at end of file diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx deleted file mode 100644 index ca6a8d9..0000000 --- a/src/components/ActionButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' - -export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' - -interface ActionButtonProps { - action: ActionType - isInstalled: boolean - isWorking: boolean - onClick: () => void - disabled?: boolean -} - -const ActionButton: React.FC = ({ - action, - isInstalled, - isWorking, - onClick, - disabled = false, -}) => { - const getButtonText = () => { - if (isWorking) return 'Working...' - - const isCream = action.includes('cream') - const product = isCream ? 'CreamLinux' : 'SmokeAPI' - - return isInstalled ? `Uninstall ${product}` : `Install ${product}` - } - - const getButtonClass = () => { - const baseClass = 'action-button' - return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}` - } - - return ( - - ) -} - -export default ActionButton diff --git a/src/components/DlcSelectionDialog.tsx b/src/components/DlcSelectionDialog.tsx deleted file mode 100644 index 0091db4..0000000 --- a/src/components/DlcSelectionDialog.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react' -import AnimatedCheckbox from './AnimatedCheckbox' - -interface DlcInfo { - appid: string - name: string - enabled: boolean -} - -interface DlcSelectionDialogProps { - visible: boolean - gameTitle: string - dlcs: DlcInfo[] - onClose: () => void - onConfirm: (selectedDlcs: DlcInfo[]) => void - isLoading: boolean - isEditMode?: boolean - loadingProgress?: number - estimatedTimeLeft?: string -} - -const DlcSelectionDialog: React.FC = ({ - visible, - gameTitle, - dlcs, - onClose, - onConfirm, - isLoading, - isEditMode = false, - loadingProgress = 0, - estimatedTimeLeft = '', -}) => { - const [selectedDlcs, setSelectedDlcs] = useState([]) - const [showContent, setShowContent] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [selectAll, setSelectAll] = useState(true) - const [initialized, setInitialized] = useState(false) - - // Initialize selected DLCs when DLC list changes - useEffect(() => { - if (visible && dlcs.length > 0 && !initialized) { - setSelectedDlcs(dlcs) - - // Determine initial selectAll state based on if all DLCs are enabled - const allSelected = dlcs.every((dlc) => dlc.enabled) - setSelectAll(allSelected) - - // Mark as initialized so we don't reset selections on subsequent DLC additions - setInitialized(true) - } - }, [visible, dlcs, initialized]) - - // Handle visibility changes - useEffect(() => { - if (visible) { - // Show content immediately for better UX - const timer = setTimeout(() => { - setShowContent(true) - }, 50) - return () => clearTimeout(timer) - } else { - setShowContent(false) - setInitialized(false) // Reset initialized state when dialog closes - } - }, [visible]) - - // Memoize filtered DLCs to avoid unnecessary recalculations - const filteredDlcs = useMemo(() => { - return searchQuery.trim() === '' - ? selectedDlcs - : selectedDlcs.filter( - (dlc) => - dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) || - dlc.appid.includes(searchQuery) - ) - }, [selectedDlcs, searchQuery]) - - // Update DLC selection status - const handleToggleDlc = (appid: string) => { - setSelectedDlcs((prev) => - prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc)) - ) - } - - // Update selectAll state when individual DLC selections change - useEffect(() => { - const allSelected = selectedDlcs.every((dlc) => dlc.enabled) - setSelectAll(allSelected) - }, [selectedDlcs]) - - // Handle new DLCs being added while dialog is already open - useEffect(() => { - if (initialized && dlcs.length > selectedDlcs.length) { - // Find new DLCs that aren't in our current selection - const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid)) - const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid)) - - // Add new DLCs to our selection, maintaining their enabled state - if (newDlcs.length > 0) { - setSelectedDlcs((prev) => [...prev, ...newDlcs]) - } - } - }, [dlcs, selectedDlcs, initialized]) - - const handleToggleSelectAll = () => { - const newSelectAllState = !selectAll - setSelectAll(newSelectAllState) - - setSelectedDlcs((prev) => - prev.map((dlc) => ({ - ...dlc, - enabled: newSelectAllState, - })) - ) - } - - const handleConfirm = () => { - onConfirm(selectedDlcs) - } - - // Modified to prevent closing when loading - const handleOverlayClick = (e: React.MouseEvent) => { - // Prevent clicks from propagating through the overlay - e.stopPropagation() - - // Only allow closing via overlay click if not loading - if (e.target === e.currentTarget && !isLoading) { - onClose() - } - } - - // Count selected DLCs - const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length - - // Format loading message to show total number of DLCs found - const getLoadingInfoText = () => { - if (isLoading && loadingProgress < 100) { - return ` (Loading more DLCs...)` - } else if (dlcs.length > 0) { - return ` (Total DLCs: ${dlcs.length})` - } - return '' - } - - if (!visible) return null - - return ( -
-
-
-

{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}

-
- {gameTitle} - - {selectedCount} of {selectedDlcs.length} DLCs selected - {getLoadingInfoText()} - -
-
- -
- setSearchQuery(e.target.value)} - className="dlc-search-input" - /> -
- -
-
- - {isLoading && ( -
-
-
-
-
- Loading DLCs: {loadingProgress}% - {estimatedTimeLeft && ( - Est. time left: {estimatedTimeLeft} - )} -
-
- )} - -
- {selectedDlcs.length > 0 ? ( -
    - {filteredDlcs.map((dlc) => ( -
  • - handleToggleDlc(dlc.appid)} - label={dlc.name} - sublabel={`ID: ${dlc.appid}`} - /> -
  • - ))} - {isLoading && ( -
  • -
    -
  • - )} -
- ) : ( -
-
-

Loading DLC information...

-
- )} -
- -
- - -
-
-
- ) -} - -export default DlcSelectionDialog diff --git a/src/components/ImagePreloader.tsx b/src/components/ImagePreloader.tsx deleted file mode 100644 index a03ffa2..0000000 --- a/src/components/ImagePreloader.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useEffect } from 'react' -import { findBestGameImage } from '../services/ImageService' - -interface ImagePreloaderProps { - gameIds: string[] - onComplete?: () => void -} - -const ImagePreloader: React.FC = ({ gameIds, onComplete }) => { - useEffect(() => { - const preloadImages = async () => { - try { - // Only preload the first batch for performance (10 images max) - const batchToPreload = gameIds.slice(0, 10) - - // Load images in parallel - await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id))) - - if (onComplete) { - onComplete() - } - } catch (error) { - console.error('Error preloading images:', error) - // Continue even if there's an error - if (onComplete) { - onComplete() - } - } - } - - if (gameIds.length > 0) { - preloadImages() - } else if (onComplete) { - onComplete() - } - }, [gameIds, onComplete]) - - return
{/* Hidden element, just used for preloading */}
-} - -export default ImagePreloader diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx deleted file mode 100644 index 3b4b461..0000000 --- a/src/components/Sidebar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -interface SidebarProps { - setFilter: (filter: string) => void - currentFilter: string -} - -const Sidebar: React.FC = ({ setFilter, currentFilter }) => { - return ( -
-

Library

-
    -
  • setFilter('all')}> - All Games -
  • -
  • setFilter('native')} - > - Native -
  • -
  • setFilter('proton')} - > - Proton Required -
  • -
-
- ) -} - -export default Sidebar diff --git a/src/components/buttons/ActionButton.tsx b/src/components/buttons/ActionButton.tsx new file mode 100644 index 0000000..582c35a --- /dev/null +++ b/src/components/buttons/ActionButton.tsx @@ -0,0 +1,59 @@ +import { FC } from 'react' +import Button, { ButtonVariant } from '../buttons/Button' + +// Define available action types +export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' + +interface ActionButtonProps { + action: ActionType + isInstalled: boolean + isWorking: boolean + onClick: () => void + disabled?: boolean + className?: string +} + +/** + * Specialized button for game installation actions + */ +const ActionButton: FC = ({ + action, + isInstalled, + isWorking, + onClick, + disabled = false, + className = '', +}) => { + // Determine button text based on state + const getButtonText = () => { + if (isWorking) return 'Working...' + + const isCream = action.includes('cream') + const product = isCream ? 'CreamLinux' : 'SmokeAPI' + + return isInstalled ? `Uninstall ${product}` : `Install ${product}` + } + + // Map to our button variant + const getButtonVariant = (): ButtonVariant => { + // For uninstall actions, use danger variant + if (isInstalled) return 'danger' + // For install actions, use success variant + return 'success' + } + + return ( + + ) +} + +export default ActionButton \ No newline at end of file diff --git a/src/components/AnimatedCheckbox.tsx b/src/components/buttons/AnimatedCheckbox.tsx similarity index 61% rename from src/components/AnimatedCheckbox.tsx rename to src/components/buttons/AnimatedCheckbox.tsx index 2acb67a..459ab69 100644 --- a/src/components/AnimatedCheckbox.tsx +++ b/src/components/buttons/AnimatedCheckbox.tsx @@ -1,25 +1,32 @@ -import React from 'react' - interface AnimatedCheckboxProps { - checked: boolean - onChange: () => void - label?: string - sublabel?: string - className?: string + checked: boolean; + onChange: () => void; + label?: string; + sublabel?: string; + className?: string; } -const AnimatedCheckbox: React.FC = ({ +/** + * Animated checkbox component with optional label and sublabel + */ +const AnimatedCheckbox = ({ checked, onChange, label, sublabel, className = '', -}) => { +}: AnimatedCheckboxProps) => { return (