mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-05 19:45:36 -05:00
Initial changes
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T> {
|
||||
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<PathBuf> {
|
||||
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<Vec<crate::dlc_manager::DlcInfoWithState>> {
|
||||
// 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<T>(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<T>(key: &str, ttl_hours: u64) -> Option<T>
|
||||
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<Vec<crate::installer::Game>> {
|
||||
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<Vec<DlcInfoWithState>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<DlcInfoWithState>,
|
||||
timestamp: Instant,
|
||||
#[allow(dead_code)]
|
||||
data: Vec<DlcInfoWithState>,
|
||||
#[allow(dead_code)]
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// Structure to hold the state of installed games
|
||||
struct AppState {
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, 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<Vec<Game>, 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<AppState>) -> Result<Game, String> {
|
||||
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<Game, String> {
|
||||
// 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<Vec<DlcInfoWithState>, 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::<Vec<_>>();
|
||||
// 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::<Vec<_>>();
|
||||
|
||||
// Cache in memory for this session (but not on disk)
|
||||
let state = app_handle.state::<AppState>();
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
||||
let state = app_handle.state::<AppState>();
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
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::<Vec<_>>();
|
||||
// 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::<Vec<_>>();
|
||||
|
||||
// Update in-memory cache without storing to disk
|
||||
let state = app_handle.state::<AppState>();
|
||||
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::<AppState>();
|
||||
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<Vec<String>, 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<DlcInfoWithState>,
|
||||
game_path: String,
|
||||
dlcs: Vec<DlcInfoWithState>,
|
||||
) -> 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<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
game_id: String,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
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::<AppState>();
|
||||
// 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::<AppState>();
|
||||
|
||||
// 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<dyn std::error::Error>> {
|
||||
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");
|
||||
}
|
||||
938
src/App.tsx
938
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<Game[]>([])
|
||||
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<string | null>(null)
|
||||
const refreshInProgress = useRef(false)
|
||||
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false)
|
||||
const dlcFetchController = useRef<AbortController | null>(null)
|
||||
const activeDlcFetchId = useRef<string | null>(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<Game[]>('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<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
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
|
||||
return <InitialLoadingScreen
|
||||
message={scanProgress.message}
|
||||
progress={scanProgress.progress}
|
||||
/>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Animated background */}
|
||||
<AnimatedBackground />
|
||||
<ErrorBoundary>
|
||||
<div className="app-container">
|
||||
{/* Animated background */}
|
||||
<AnimatedBackground />
|
||||
|
||||
<Header onRefresh={loadGames} onSearch={handleSearchChange} searchQuery={searchQuery} />
|
||||
<div className="main-content">
|
||||
<Sidebar setFilter={setFilter} currentFilter={filter} />
|
||||
{error ? (
|
||||
<div className="error-message">
|
||||
<h3>Error Loading Games</h3>
|
||||
<p>{error}</p>
|
||||
<button onClick={loadGames}>Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<GameList
|
||||
games={filteredGames}
|
||||
isLoading={isLoading}
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
/>
|
||||
)}
|
||||
{/* Header with search */}
|
||||
<Header
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={handleSearchChange}
|
||||
searchQuery={searchQuery}
|
||||
refreshDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="main-content">
|
||||
{/* Sidebar for filtering */}
|
||||
<Sidebar setFilter={setFilter} currentFilter={filter} />
|
||||
|
||||
{/* Show error or game list */}
|
||||
{error ? (
|
||||
<div className="error-message">
|
||||
<h3>Error Loading Games</h3>
|
||||
<p>{error}</p>
|
||||
<button onClick={handleRefresh}>Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<GameList
|
||||
games={filteredGames}
|
||||
isLoading={isLoading}
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Dialog */}
|
||||
<ProgressDialog
|
||||
visible={progressDialog.visible}
|
||||
title={progressDialog.title}
|
||||
message={progressDialog.message}
|
||||
progress={progressDialog.progress}
|
||||
showInstructions={progressDialog.showInstructions}
|
||||
instructions={progressDialog.instructions}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
|
||||
{/* DLC Selection Dialog */}
|
||||
<DlcSelectionDialog
|
||||
visible={dlcDialog.visible}
|
||||
gameTitle={dlcDialog.gameTitle}
|
||||
dlcs={dlcDialog.dlcs}
|
||||
isLoading={dlcDialog.isLoading}
|
||||
isEditMode={dlcDialog.isEditMode}
|
||||
loadingProgress={dlcDialog.progress}
|
||||
estimatedTimeLeft={dlcDialog.timeLeft}
|
||||
onClose={handleDlcDialogClose}
|
||||
onConfirm={handleDlcConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Dialog */}
|
||||
<ProgressDialog
|
||||
visible={progressDialog.visible}
|
||||
title={progressDialog.title}
|
||||
message={progressDialog.message}
|
||||
progress={progressDialog.progress}
|
||||
showInstructions={progressDialog.showInstructions}
|
||||
instructions={progressDialog.instructions}
|
||||
onClose={handleCloseProgressDialog}
|
||||
/>
|
||||
|
||||
{/* DLC Selection Dialog */}
|
||||
<DlcSelectionDialog
|
||||
visible={dlcDialog.visible}
|
||||
gameTitle={dlcDialog.gameTitle}
|
||||
dlcs={dlcDialog.dlcs}
|
||||
isLoading={dlcDialog.isLoading}
|
||||
isEditMode={dlcDialog.isEditMode}
|
||||
loadingProgress={dlcDialog.progress}
|
||||
estimatedTimeLeft={dlcDialog.timeLeft}
|
||||
onClose={handleDlcDialogClose}
|
||||
onConfirm={handleDlcConfirm}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
@@ -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<ActionButtonProps> = ({
|
||||
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 (
|
||||
<button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButton
|
||||
@@ -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<DlcSelectionDialogProps> = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
dlcs,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
isEditMode = false,
|
||||
loadingProgress = 0,
|
||||
estimatedTimeLeft = '',
|
||||
}) => {
|
||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
|
||||
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<HTMLDivElement>) => {
|
||||
// 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 (
|
||||
<div
|
||||
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}>
|
||||
<div className="dlc-dialog-header">
|
||||
<h3>{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}</h3>
|
||||
<div className="dlc-game-info">
|
||||
<span className="game-title">{gameTitle}</span>
|
||||
<span className="dlc-count">
|
||||
{selectedCount} of {selectedDlcs.length} DLCs selected
|
||||
{getLoadingInfoText()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
<div className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="dlc-loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||
</div>
|
||||
<div className="loading-details">
|
||||
<span>Loading DLCs: {loadingProgress}%</span>
|
||||
{estimatedTimeLeft && (
|
||||
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dlc-list-container">
|
||||
{selectedDlcs.length > 0 ? (
|
||||
<ul className="dlc-list">
|
||||
{filteredDlcs.map((dlc) => (
|
||||
<li key={dlc.appid} className="dlc-item">
|
||||
<AnimatedCheckbox
|
||||
checked={dlc.enabled}
|
||||
onChange={() => handleToggleDlc(dlc.appid)}
|
||||
label={dlc.name}
|
||||
sublabel={`ID: ${dlc.appid}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{isLoading && (
|
||||
<li className="dlc-item dlc-item-loading">
|
||||
<div className="loading-pulse"></div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="dlc-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading DLC information...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dlc-dialog-actions">
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
|
||||
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DlcSelectionDialog
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { findBestGameImage } from '../services/ImageService'
|
||||
|
||||
interface ImagePreloaderProps {
|
||||
gameIds: string[]
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ 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 <div className="image-preloader">{/* Hidden element, just used for preloading */}</div>
|
||||
}
|
||||
|
||||
export default ImagePreloader
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface SidebarProps {
|
||||
setFilter: (filter: string) => void
|
||||
currentFilter: string
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<h2>Library</h2>
|
||||
<ul className="filter-list">
|
||||
<li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||
All Games
|
||||
</li>
|
||||
<li
|
||||
className={currentFilter === 'native' ? 'active' : ''}
|
||||
onClick={() => setFilter('native')}
|
||||
>
|
||||
Native
|
||||
</li>
|
||||
<li
|
||||
className={currentFilter === 'proton' ? 'active' : ''}
|
||||
onClick={() => setFilter('proton')}
|
||||
>
|
||||
Proton Required
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
59
src/components/buttons/ActionButton.tsx
Normal file
59
src/components/buttons/ActionButton.tsx
Normal file
@@ -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<ActionButtonProps> = ({
|
||||
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 (
|
||||
<Button
|
||||
variant={getButtonVariant()}
|
||||
isLoading={isWorking}
|
||||
onClick={onClick}
|
||||
disabled={disabled || isWorking}
|
||||
fullWidth
|
||||
className={`action-button ${className}`}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButton
|
||||
@@ -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<AnimatedCheckboxProps> = ({
|
||||
/**
|
||||
* Animated checkbox component with optional label and sublabel
|
||||
*/
|
||||
const AnimatedCheckbox = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
sublabel,
|
||||
className = '',
|
||||
}) => {
|
||||
}: AnimatedCheckboxProps) => {
|
||||
return (
|
||||
<label className={`animated-checkbox ${className}`}>
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="checkbox-original"
|
||||
/>
|
||||
|
||||
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
||||
<svg viewBox="0 0 24 24" className="checkmark-icon">
|
||||
<svg viewBox="0 0 24 24" className="checkmark-icon" aria-hidden="true">
|
||||
<path
|
||||
className={`checkmark ${checked ? 'checked' : ''}`}
|
||||
d="M5 12l5 5L20 7"
|
||||
@@ -31,6 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{(label || sublabel) && (
|
||||
<div className="checkbox-content">
|
||||
{label && <span className="checkbox-label">{label}</span>}
|
||||
@@ -41,4 +49,4 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedCheckbox
|
||||
export default AnimatedCheckbox
|
||||
67
src/components/buttons/Button.tsx
Normal file
67
src/components/buttons/Button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FC, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
|
||||
export type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component with different variants, sizes and states
|
||||
*/
|
||||
const Button: FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
// Size class mapping
|
||||
const sizeClass = {
|
||||
small: 'btn-sm',
|
||||
medium: 'btn-md',
|
||||
large: 'btn-lg',
|
||||
}[size];
|
||||
|
||||
// Variant class mapping
|
||||
const variantClass = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
danger: 'btn-danger',
|
||||
success: 'btn-success',
|
||||
warning: 'btn-warning',
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn ${variantClass} ${sizeClass} ${fullWidth ? 'btn-full' : ''} ${
|
||||
isLoading ? 'btn-loading' : ''
|
||||
} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="btn-spinner">
|
||||
<span className="spinner"></span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
|
||||
<span className="btn-text">{children}</span>
|
||||
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
8
src/components/buttons/index.ts
Normal file
8
src/components/buttons/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Export all button components
|
||||
export { default as Button } from './Button';
|
||||
export { default as ActionButton } from './ActionButton';
|
||||
export { default as AnimatedCheckbox } from './AnimatedCheckbox';
|
||||
|
||||
// Export types
|
||||
export type { ButtonVariant, ButtonSize } from './Button';
|
||||
export type { ActionType } from './ActionButton';
|
||||
75
src/components/common/LoadingIndicator.tsx
Normal file
75
src/components/common/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type LoadingType = 'spinner' | 'dots' | 'progress'
|
||||
export type LoadingSize = 'small' | 'medium' | 'large'
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
size?: LoadingSize;
|
||||
type?: LoadingType;
|
||||
message?: string;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versatile loading indicator component
|
||||
* Supports multiple visual styles and sizes
|
||||
*/
|
||||
const LoadingIndicator = ({
|
||||
size = 'medium',
|
||||
type = 'spinner',
|
||||
message,
|
||||
progress = 0,
|
||||
className = '',
|
||||
}: LoadingIndicatorProps) => {
|
||||
// Size class mapping
|
||||
const sizeClass = {
|
||||
small: 'loading-small',
|
||||
medium: 'loading-medium',
|
||||
large: 'loading-large',
|
||||
}[size]
|
||||
|
||||
// Render loading indicator based on type
|
||||
const renderLoadingIndicator = (): ReactNode => {
|
||||
switch (type) {
|
||||
case 'spinner':
|
||||
return <div className="loading-spinner"></div>
|
||||
|
||||
case 'dots':
|
||||
return (
|
||||
<div className="loading-dots">
|
||||
<div className="dot dot-1"></div>
|
||||
<div className="dot dot-2"></div>
|
||||
<div className="dot dot-3"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div className="loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="loading-spinner"></div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`loading-indicator ${sizeClass} ${className}`}>
|
||||
{renderLoadingIndicator()}
|
||||
{message && <p className="loading-message">{message}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingIndicator
|
||||
3
src/components/common/index.ts
Normal file
3
src/components/common/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LoadingIndicator } from './LoadingIndicator';
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator';
|
||||
82
src/components/dialogs/Dialog.tsx
Normal file
82
src/components/dialogs/Dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
export interface DialogProps {
|
||||
visible: boolean;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
preventBackdropClose?: boolean;
|
||||
children: ReactNode;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showAnimationOnUnmount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base Dialog component that serves as a container for dialog content
|
||||
* Used with DialogHeader, DialogBody, and DialogFooter components
|
||||
*/
|
||||
const Dialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
className = '',
|
||||
preventBackdropClose = false,
|
||||
children,
|
||||
size = 'medium',
|
||||
showAnimationOnUnmount = true,
|
||||
}: DialogProps) => {
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const [shouldRender, setShouldRender] = useState(visible)
|
||||
|
||||
// Handle visibility changes with animations
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true)
|
||||
// Small delay to trigger entrance animation after component is mounted
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (showAnimationOnUnmount) {
|
||||
// First hide content with animation
|
||||
setShowContent(false)
|
||||
// Then unmount after animation completes
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false)
|
||||
}, 300) // Match this with your CSS transition duration
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
// Immediately unmount without animation
|
||||
setShowContent(false)
|
||||
setShouldRender(false)
|
||||
}
|
||||
}, [visible, showAnimationOnUnmount])
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget && !preventBackdropClose && onClose) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render anything if dialog shouldn't be shown
|
||||
if (!shouldRender) return null
|
||||
|
||||
const sizeClass = {
|
||||
small: 'dialog-small',
|
||||
medium: 'dialog-medium',
|
||||
large: 'dialog-large',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dialog
|
||||
31
src/components/dialogs/DialogActions.tsx
Normal file
31
src/components/dialogs/DialogActions.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogActionsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions container for dialog footers
|
||||
* Provides consistent spacing and alignment for action buttons
|
||||
*/
|
||||
const DialogActions = ({
|
||||
children,
|
||||
className = '',
|
||||
align = 'end'
|
||||
}: DialogActionsProps) => {
|
||||
const alignClass = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end'
|
||||
}[align];
|
||||
|
||||
return (
|
||||
<div className={`dialog-actions ${alignClass} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogActions
|
||||
20
src/components/dialogs/DialogBody.tsx
Normal file
20
src/components/dialogs/DialogBody.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogBodyProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body component for dialogs
|
||||
* Contains the main content with scrolling capability
|
||||
*/
|
||||
const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
|
||||
return (
|
||||
<div className={`dialog-body ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogBody
|
||||
20
src/components/dialogs/DialogFooter.tsx
Normal file
20
src/components/dialogs/DialogFooter.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for dialogs
|
||||
* Contains action buttons and optional status information
|
||||
*/
|
||||
const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
|
||||
return (
|
||||
<div className={`dialog-footer ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogFooter
|
||||
30
src/components/dialogs/DialogHeader.tsx
Normal file
30
src/components/dialogs/DialogHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component for dialogs
|
||||
* Contains the title and optional close button
|
||||
*/
|
||||
const DialogHeader = ({ children, className = '', onClose }: DialogHeaderProps) => {
|
||||
return (
|
||||
<div className={`dialog-header ${className}`}>
|
||||
{children}
|
||||
{onClose && (
|
||||
<button
|
||||
className="dialog-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogHeader
|
||||
221
src/components/dialogs/DlcSelectionDialog.tsx
Normal file
221
src/components/dialogs/DlcSelectionDialog.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { DlcInfo } from '@/types'
|
||||
|
||||
export interface DlcSelectionDialogProps {
|
||||
visible: boolean;
|
||||
gameTitle: string;
|
||||
dlcs: DlcInfo[];
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedDlcs: DlcInfo[]) => void;
|
||||
isLoading: boolean;
|
||||
isEditMode?: boolean;
|
||||
loadingProgress?: number;
|
||||
estimatedTimeLeft?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DLC Selection Dialog component
|
||||
* Allows users to select which DLCs they want to enable
|
||||
*/
|
||||
const DlcSelectionDialog = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
dlcs,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
isEditMode = false,
|
||||
loadingProgress = 0,
|
||||
estimatedTimeLeft = '',
|
||||
}: DlcSelectionDialogProps) => {
|
||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectAll, setSelectAll] = useState(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Initialize selected DLCs when DLC list changes
|
||||
useEffect(() => {
|
||||
if (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)
|
||||
}
|
||||
}, [dlcs, initialized])
|
||||
|
||||
// Memoize filtered DLCs to avoid unnecessary recalculations
|
||||
const filteredDlcs = React.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)
|
||||
}
|
||||
|
||||
// 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 ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
size="large"
|
||||
preventBackdropClose={isLoading}
|
||||
>
|
||||
<DialogHeader onClose={onClose}>
|
||||
<h3>{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}</h3>
|
||||
<div className="dlc-game-info">
|
||||
<span className="game-title">{gameTitle}</span>
|
||||
<span className="dlc-count">
|
||||
{selectedCount} of {selectedDlcs.length} DLCs selected
|
||||
{getLoadingInfoText()}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
<div className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="dlc-loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||
</div>
|
||||
<div className="loading-details">
|
||||
<span>Loading DLCs: {loadingProgress}%</span>
|
||||
{estimatedTimeLeft && (
|
||||
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogBody className="dlc-list-container">
|
||||
{selectedDlcs.length > 0 ? (
|
||||
<ul className="dlc-list">
|
||||
{filteredDlcs.map((dlc) => (
|
||||
<li key={dlc.appid} className="dlc-item">
|
||||
<AnimatedCheckbox
|
||||
checked={dlc.enabled}
|
||||
onChange={() => handleToggleDlc(dlc.appid)}
|
||||
label={dlc.name}
|
||||
sublabel={`ID: ${dlc.appid}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{isLoading && (
|
||||
<li className="dlc-item dlc-item-loading">
|
||||
<div className="loading-pulse"></div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="dlc-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading DLC information...</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default DlcSelectionDialog
|
||||
@@ -1,49 +1,42 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button } from '@/components/buttons'
|
||||
|
||||
interface InstructionInfo {
|
||||
type: string
|
||||
command: string
|
||||
game_title: string
|
||||
dlc_count?: number
|
||||
export interface InstallationInstructions {
|
||||
type: string;
|
||||
command: string;
|
||||
game_title: string;
|
||||
dlc_count?: number;
|
||||
}
|
||||
|
||||
interface ProgressDialogProps {
|
||||
title: string
|
||||
message: string
|
||||
progress: number // 0-100
|
||||
visible: boolean
|
||||
showInstructions?: boolean
|
||||
instructions?: InstructionInfo
|
||||
onClose?: () => void
|
||||
export interface ProgressDialogProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
progress: number;
|
||||
showInstructions?: boolean;
|
||||
instructions?: InstallationInstructions;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
/**
|
||||
* ProgressDialog component
|
||||
* Shows installation progress with a progress bar and optional instructions
|
||||
*/
|
||||
const ProgressDialog = ({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
progress,
|
||||
visible,
|
||||
showInstructions = false,
|
||||
instructions,
|
||||
onClose,
|
||||
}) => {
|
||||
}: ProgressDialogProps) => {
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
|
||||
// Reset copy state when dialog visibility changes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setCopySuccess(false)
|
||||
setShowContent(false)
|
||||
} else {
|
||||
// Add a small delay to trigger the entrance animation
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const handleCopyCommand = () => {
|
||||
if (instructions?.command) {
|
||||
@@ -57,29 +50,6 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowContent(false)
|
||||
// Delay closing to allow exit animation
|
||||
setTimeout(() => {
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Prevent closing when in progress
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Always prevent propagation
|
||||
e.stopPropagation()
|
||||
|
||||
// Only allow clicking outside to close if we're done processing (100%)
|
||||
// and showing instructions or if explicitly allowed via a prop
|
||||
if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
|
||||
handleClose()
|
||||
}
|
||||
// Otherwise, do nothing - require using the close button
|
||||
}
|
||||
|
||||
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
||||
const showCopyButton =
|
||||
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
||||
@@ -147,14 +117,17 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
const isCloseButtonEnabled = showInstructions || progress >= 100
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
onClick={handleOverlayClick}
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={isCloseButtonEnabled ? onClose : undefined}
|
||||
size="medium"
|
||||
preventBackdropClose={!isCloseButtonEnabled}
|
||||
>
|
||||
<div
|
||||
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<h3>{title}</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<p>{message}</p>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
@@ -174,36 +147,34 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
<div className={getCommandBoxClass()}>
|
||||
<pre className="selectable-text">{instructions.command}</pre>
|
||||
</div>
|
||||
|
||||
<div className="action-buttons">
|
||||
{showCopyButton && (
|
||||
<button className="copy-button" onClick={handleCopyCommand}>
|
||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
disabled={!isCloseButtonEnabled}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
{showInstructions && showCopyButton && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCopyCommand}
|
||||
>
|
||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Show close button even if no instructions */}
|
||||
{!showInstructions && progress >= 100 && (
|
||||
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
||||
<button className="close-button" onClick={handleClose}>
|
||||
{isCloseButtonEnabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={!isCloseButtonEnabled}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressDialog
|
||||
export default ProgressDialog
|
||||
17
src/components/dialogs/index.ts
Normal file
17
src/components/dialogs/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Export all dialog components
|
||||
export { default as Dialog } from './Dialog';
|
||||
export { default as DialogHeader } from './DialogHeader';
|
||||
export { default as DialogBody } from './DialogBody';
|
||||
export { default as DialogFooter } from './DialogFooter';
|
||||
export { default as DialogActions } from './DialogActions';
|
||||
export { default as ProgressDialog } from './ProgressDialog';
|
||||
export { default as DlcSelectionDialog } from './DlcSelectionDialog';
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog';
|
||||
export type { DialogHeaderProps } from './DialogHeader';
|
||||
export type { DialogBodyProps } from './DialogBody';
|
||||
export type { DialogFooterProps } from './DialogFooter';
|
||||
export type { DialogActionsProps } from './DialogActions';
|
||||
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog';
|
||||
export type { DlcSelectionDialogProps } from './DlcSelectionDialog';
|
||||
@@ -1,18 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { findBestGameImage } from '../services/ImageService'
|
||||
import { ActionType } from './ActionButton'
|
||||
|
||||
interface Game {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
platform?: string
|
||||
native: boolean
|
||||
api_files: string[]
|
||||
cream_installed?: boolean
|
||||
smoke_installed?: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
import { useState, useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
import { Game } from '@/types'
|
||||
import { ActionButton, ActionType, Button } from '@/components/buttons'
|
||||
|
||||
interface GameItemProps {
|
||||
game: Game
|
||||
@@ -20,7 +9,11 @@ interface GameItemProps {
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
/**
|
||||
* Individual game card component
|
||||
* Displays game information and action buttons
|
||||
*/
|
||||
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
@@ -116,58 +109,51 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{shouldShowCream && (
|
||||
<button
|
||||
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
|
||||
<ActionButton
|
||||
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
||||
isInstalled={!!game.cream_installed}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleCreamAction}
|
||||
disabled={!!game.installing}
|
||||
>
|
||||
{game.installing
|
||||
? 'Working...'
|
||||
: game.cream_installed
|
||||
? 'Uninstall CreamLinux'
|
||||
: 'Install CreamLinux'}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||
{shouldShowSmoke && (
|
||||
<button
|
||||
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
|
||||
<ActionButton
|
||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||
isInstalled={!!game.smoke_installed}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleSmokeAction}
|
||||
disabled={!!game.installing}
|
||||
>
|
||||
{game.installing
|
||||
? 'Working...'
|
||||
: game.smoke_installed
|
||||
? 'Uninstall SmokeAPI'
|
||||
: 'Install SmokeAPI'}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show message for Proton games without API files */}
|
||||
{isProtonNoApi && (
|
||||
<div className="api-not-found-message">
|
||||
<span>Steam API DLL not found</span>
|
||||
<button
|
||||
className="rescan-button"
|
||||
<Button
|
||||
variant="warning"
|
||||
size="small"
|
||||
onClick={() => onAction(game.id, 'install_smoke')}
|
||||
title="Attempt to scan again"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<button
|
||||
className="edit-button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
title="Manage DLCs"
|
||||
className="edit-button"
|
||||
>
|
||||
Manage DLCs
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,4 +161,4 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default GameItem
|
||||
export default GameItem
|
||||
@@ -1,19 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import GameItem from './GameItem'
|
||||
import ImagePreloader from './ImagePreloader'
|
||||
import { ActionType } from './ActionButton'
|
||||
|
||||
interface Game {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
platform?: string
|
||||
native: boolean
|
||||
api_files: string[]
|
||||
cream_installed?: boolean
|
||||
smoke_installed?: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {GameItem, ImagePreloader} from '@/components/games'
|
||||
import { ActionType } from '@/components/buttons'
|
||||
import { Game } from '@/types'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
|
||||
interface GameListProps {
|
||||
games: Game[]
|
||||
@@ -22,10 +11,14 @@ interface GameListProps {
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title using useMemo to avoid re-sorting on each render
|
||||
// Sort games alphabetically by title
|
||||
const sortedGames = useMemo(() => {
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||
}, [games])
|
||||
@@ -35,25 +28,22 @@ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit
|
||||
setImagesPreloaded(false)
|
||||
}, [games])
|
||||
|
||||
// Debug log to help diagnose game states
|
||||
useEffect(() => {
|
||||
if (games.length > 0) {
|
||||
console.log('Games state in GameList:', games.length, 'games')
|
||||
}
|
||||
}, [games])
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<div className="loading-indicator">Scanning for games...</div>
|
||||
<LoadingIndicator
|
||||
type="spinner"
|
||||
size="large"
|
||||
message="Scanning for games..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Games ({games.length})</h2>
|
||||
@@ -78,4 +68,4 @@ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit
|
||||
)
|
||||
}
|
||||
|
||||
export default GameList
|
||||
export default GameList
|
||||
61
src/components/games/ImagePreloader.tsx
Normal file
61
src/components/games/ImagePreloader.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
|
||||
interface ImagePreloaderProps {
|
||||
gameIds: string[]
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads game images to prevent flickering
|
||||
* Only used internally by GameList component
|
||||
*/
|
||||
const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
|
||||
useEffect(() => {
|
||||
const preloadImages = async () => {
|
||||
try {
|
||||
// Only preload the first batch for performance (10 images max)
|
||||
const batchToPreload = gameIds.slice(0, 10)
|
||||
|
||||
// Track loading progress
|
||||
let loadedCount = 0
|
||||
const totalImages = batchToPreload.length
|
||||
|
||||
// Load images in parallel
|
||||
await Promise.allSettled(
|
||||
batchToPreload.map(async (id) => {
|
||||
await findBestGameImage(id)
|
||||
loadedCount++
|
||||
|
||||
// If all images are loaded, call onComplete
|
||||
if (loadedCount === totalImages && onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Fallback if Promise.allSettled doesn't trigger onComplete
|
||||
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])
|
||||
|
||||
// Invisible component that just handles preloading
|
||||
return null
|
||||
}
|
||||
|
||||
export default ImagePreloader
|
||||
4
src/components/games/index.ts
Normal file
4
src/components/games/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Export all game components
|
||||
export { default as GameList } from './GameList';
|
||||
export { default as GameItem } from './GameItem';
|
||||
export { default as ImagePreloader } from './ImagePreloader';
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const AnimatedBackground: React.FC = () => {
|
||||
/**
|
||||
* Animated background component that draws an interactive particle effect
|
||||
*/
|
||||
const AnimatedBackground = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +36,7 @@ const AnimatedBackground: React.FC = () => {
|
||||
color: string
|
||||
}
|
||||
|
||||
// Color palette
|
||||
// Color palette matching our theme
|
||||
const colors = [
|
||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||
'rgba(155, 125, 255, 0.5)', // purple
|
||||
@@ -77,7 +80,7 @@ const AnimatedBackground: React.FC = () => {
|
||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
|
||||
ctx.fill()
|
||||
|
||||
// Connect particles
|
||||
// Connect particles that are close to each other
|
||||
particles.forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x
|
||||
const dy = particle.y - otherParticle.y
|
||||
@@ -100,6 +103,7 @@ const AnimatedBackground: React.FC = () => {
|
||||
// Start animation
|
||||
animate()
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', setCanvasSize)
|
||||
}
|
||||
@@ -109,18 +113,9 @@ const AnimatedBackground: React.FC = () => {
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="animated-background"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedBackground
|
||||
export default AnimatedBackground
|
||||
81
src/components/layout/ErrorBoundary.tsx
Normal file
81
src/components/layout/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import { Button } from '@/components/buttons'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component to catch and display runtime errors
|
||||
*/
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log the error
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
|
||||
// Call the onError callback if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Something went wrong</h2>
|
||||
|
||||
<details>
|
||||
<summary>Error details</summary>
|
||||
<p>{this.state.error?.toString()}</p>
|
||||
</details>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleReset}
|
||||
className="error-retry-button"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/buttons'
|
||||
|
||||
interface HeaderProps {
|
||||
onRefresh: () => void
|
||||
@@ -7,19 +7,28 @@ interface HeaderProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
/**
|
||||
* Application header component
|
||||
* Contains the app title, search input, and refresh button
|
||||
*/
|
||||
const Header = ({
|
||||
onRefresh,
|
||||
refreshDisabled = false,
|
||||
onSearch,
|
||||
searchQuery,
|
||||
}) => {
|
||||
}: HeaderProps) => {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<h1>CreamLinux</h1>
|
||||
<div className="header-controls">
|
||||
<button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshDisabled}
|
||||
className="refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</Button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
@@ -32,4 +41,4 @@ const Header: React.FC<HeaderProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
export default Header
|
||||
@@ -1,15 +1,35 @@
|
||||
import React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface InitialLoadingScreenProps {
|
||||
message: string
|
||||
progress: number
|
||||
message: string;
|
||||
progress: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
|
||||
/**
|
||||
* Initial loading screen displayed when the app first loads
|
||||
*/
|
||||
const InitialLoadingScreen = ({
|
||||
message,
|
||||
progress,
|
||||
onComplete
|
||||
}: InitialLoadingScreenProps) => {
|
||||
// Call onComplete when progress reaches 100%
|
||||
useEffect(() => {
|
||||
if (progress >= 100 && onComplete) {
|
||||
const timer = setTimeout(() => {
|
||||
onComplete();
|
||||
}, 500); // Small delay to show completion
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [progress, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="initial-loading-screen">
|
||||
<div className="loading-content">
|
||||
<h1>CreamLinux</h1>
|
||||
|
||||
<div className="loading-animation">
|
||||
<div className="loading-circles">
|
||||
<div className="circle circle-1"></div>
|
||||
@@ -17,14 +37,17 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, pr
|
||||
<div className="circle circle-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="loading-message">{message}</p>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InitialLoadingScreen
|
||||
export default InitialLoadingScreen
|
||||
36
src/components/layout/Sidebar.tsx
Normal file
36
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
interface SidebarProps {
|
||||
setFilter: (filter: string) => void
|
||||
currentFilter: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Application sidebar component
|
||||
* Contains filters for game types
|
||||
*/
|
||||
const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
|
||||
// Available filter options
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All Games' },
|
||||
{ id: 'native', label: 'Native' },
|
||||
{ id: 'proton', label: 'Proton Required' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<h2>Library</h2>
|
||||
<ul className="filter-list">
|
||||
{filters.map(filter => (
|
||||
<li
|
||||
key={filter.id}
|
||||
className={currentFilter === filter.id ? 'active' : ''}
|
||||
onClick={() => setFilter(filter.id)}
|
||||
>
|
||||
{filter.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
6
src/components/layout/index.ts
Normal file
6
src/components/layout/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all layout components
|
||||
export { default as Header } from './Header';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
export { default as AnimatedBackground } from './AnimatedBackground';
|
||||
export { default as InitialLoadingScreen } from './InitialLoadingScreen';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
83
src/components/notifications/Toast.tsx
Normal file
83
src/components/notifications/Toast.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title?: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual Toast component
|
||||
* Displays a notification message with automatic dismissal
|
||||
*/
|
||||
const Toast = ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
duration = 5000, // default 5 seconds
|
||||
onDismiss
|
||||
}: ToastProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
// Handle animation on mount/unmount
|
||||
useEffect(() => {
|
||||
// Start the enter animation
|
||||
const enterTimer = setTimeout(() => {
|
||||
setVisible(true)
|
||||
}, 10)
|
||||
|
||||
// Auto-dismiss after duration, if not Infinity
|
||||
let dismissTimer: NodeJS.Timeout | null = null
|
||||
if (duration !== Infinity) {
|
||||
dismissTimer = setTimeout(() => {
|
||||
handleDismiss()
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(enterTimer)
|
||||
if (dismissTimer) clearTimeout(dismissTimer)
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
// Get icon based on toast type
|
||||
const getIcon = (): ReactNode => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓'
|
||||
case 'error':
|
||||
return '✗'
|
||||
case 'warning':
|
||||
return '⚠'
|
||||
case 'info':
|
||||
return 'ℹ'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false)
|
||||
// Give time for exit animation
|
||||
setTimeout(() => onDismiss(id), 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`toast toast-${type} ${visible ? 'visible' : ''}`}>
|
||||
<div className="toast-icon">{getIcon()}</div>
|
||||
<div className="toast-content">
|
||||
{title && <h4 className="toast-title">{title}</h4>}
|
||||
<p className="toast-message">{message}</p>
|
||||
</div>
|
||||
<button className="toast-close" onClick={handleDismiss} aria-label="Dismiss">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
47
src/components/notifications/ToastContainer.tsx
Normal file
47
src/components/notifications/ToastContainer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Toast, { ToastProps } from './Toast'
|
||||
|
||||
export type ToastPosition =
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left'
|
||||
| 'top-center'
|
||||
| 'bottom-center'
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Omit<ToastProps, 'onDismiss'>[];
|
||||
onDismiss: (id: string) => void;
|
||||
position?: ToastPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for toast notifications
|
||||
* Manages positioning and rendering of all toast notifications
|
||||
*/
|
||||
const ToastContainer = ({
|
||||
toasts,
|
||||
onDismiss,
|
||||
position = 'bottom-right',
|
||||
}: ToastContainerProps) => {
|
||||
if (toasts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`toast-container ${position}`}>
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
id={toast.id}
|
||||
type={toast.type}
|
||||
title={toast.title}
|
||||
message={toast.message}
|
||||
duration={toast.duration}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastContainer
|
||||
5
src/components/notifications/index.ts
Normal file
5
src/components/notifications/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Toast } from './Toast';
|
||||
export { default as ToastContainer } from './ToastContainer';
|
||||
|
||||
export type { ToastProps } from './Toast';
|
||||
export type { ToastPosition } from './ToastContainer';
|
||||
56
src/contexts/AppContext.tsx
Normal file
56
src/contexts/AppContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext } from 'react'
|
||||
import { Game, DlcInfo } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
|
||||
// Types for context sub-components
|
||||
export interface InstallationInstructions {
|
||||
type: string;
|
||||
command: string;
|
||||
game_title: string;
|
||||
dlc_count?: number;
|
||||
}
|
||||
|
||||
export interface DlcDialogState {
|
||||
visible: boolean;
|
||||
gameId: string;
|
||||
gameTitle: string;
|
||||
dlcs: DlcInfo[];
|
||||
isLoading: boolean;
|
||||
isEditMode: boolean;
|
||||
progress: number;
|
||||
timeLeft?: string;
|
||||
}
|
||||
|
||||
export interface ProgressDialogState {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
progress: number;
|
||||
showInstructions: boolean;
|
||||
instructions?: InstallationInstructions;
|
||||
}
|
||||
|
||||
// Define the context type
|
||||
export interface AppContextType {
|
||||
// Game state
|
||||
games: Game[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadGames: () => Promise<boolean>;
|
||||
|
||||
// DLC management
|
||||
dlcDialog: DlcDialogState;
|
||||
handleGameEdit: (gameId: string) => void;
|
||||
handleDlcDialogClose: () => void;
|
||||
|
||||
// Game actions
|
||||
progressDialog: ProgressDialogState;
|
||||
handleGameAction: (gameId: string, action: ActionType) => Promise<void>;
|
||||
handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void;
|
||||
|
||||
// Toast notifications
|
||||
showToast: (message: string, type: 'success' | 'error' | 'warning' | 'info', options?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
// Create the context with a default value
|
||||
export const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
171
src/contexts/AppProvider.tsx
Normal file
171
src/contexts/AppProvider.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AppContext, AppContextType } from './AppContext'
|
||||
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||
import { DlcInfo } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { ToastContainer } from '@/components/notifications'
|
||||
|
||||
// Context provider component
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary application context provider
|
||||
* Manages global state and provides methods for component interaction
|
||||
*/
|
||||
export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
// Use our custom hooks
|
||||
const {
|
||||
games,
|
||||
isLoading,
|
||||
error,
|
||||
loadGames,
|
||||
setGames,
|
||||
} = useGames()
|
||||
|
||||
const {
|
||||
dlcDialog,
|
||||
setDlcDialog,
|
||||
handleDlcDialogClose: closeDlcDialog,
|
||||
streamGameDlcs,
|
||||
} = useDlcManager()
|
||||
|
||||
const {
|
||||
progressDialog,
|
||||
handleGameAction: executeGameAction,
|
||||
handleDlcConfirm: executeDlcConfirm,
|
||||
} = useGameActions()
|
||||
|
||||
const {
|
||||
toasts,
|
||||
removeToast,
|
||||
success,
|
||||
error: showError,
|
||||
warning,
|
||||
info
|
||||
} = useToasts()
|
||||
|
||||
// Combined handler for game edit
|
||||
const handleGameEdit = async (gameId: string) => {
|
||||
const game = games.find(g => g.id === gameId)
|
||||
if (!game || !game.cream_installed) {
|
||||
showError("Cannot edit game: not found or CreamLinux not installed")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await streamGameDlcs(gameId)
|
||||
|
||||
setDlcDialog({
|
||||
...dlcDialog,
|
||||
visible: true,
|
||||
gameId,
|
||||
gameTitle: game.title,
|
||||
isLoading: true,
|
||||
isEditMode: true,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
showError(`Failed to load DLCs: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced game action handler with proper error reporting
|
||||
const handleGameAction = async (gameId: string, action: ActionType) => {
|
||||
const game = games.find(g => g.id === gameId)
|
||||
if (!game) {
|
||||
showError("Game not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark game as installing
|
||||
setGames(prevGames =>
|
||||
prevGames.map(g => g.id === gameId ? {...g, installing: true} : g)
|
||||
)
|
||||
|
||||
try {
|
||||
await executeGameAction(gameId, action, games)
|
||||
|
||||
// Show success message
|
||||
if (action.includes('install')) {
|
||||
success(`Successfully installed ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} for ${game.title}`)
|
||||
} else {
|
||||
success(`Successfully uninstalled ${action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'} from ${game.title}`)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Action failed: ${error}`)
|
||||
} finally {
|
||||
// Reset installing state
|
||||
setGames(prevGames =>
|
||||
prevGames.map(g => g.id === gameId ? {...g, installing: false} : g)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DLC confirmation wrapper
|
||||
const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => {
|
||||
closeDlcDialog()
|
||||
const { gameId, isEditMode } = dlcDialog
|
||||
|
||||
// Update game state to show it's installing
|
||||
setGames(prevGames =>
|
||||
prevGames.map(g => g.id === gameId ? { ...g, installing: true } : g)
|
||||
)
|
||||
|
||||
executeDlcConfirm(selectedDlcs, gameId, isEditMode, games)
|
||||
.then(() => {
|
||||
success(isEditMode
|
||||
? "DLC configuration updated successfully"
|
||||
: "CreamLinux installed with selected DLCs")
|
||||
})
|
||||
.catch(error => {
|
||||
showError(`DLC operation failed: ${error}`)
|
||||
})
|
||||
.finally(() => {
|
||||
// Reset installing state
|
||||
setGames(prevGames =>
|
||||
prevGames.map(g => g.id === gameId ? { ...g, installing: false } : g)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Generic toast show function
|
||||
const showToast = (message: string, type: 'success' | 'error' | 'warning' | 'info', options = {}) => {
|
||||
switch (type) {
|
||||
case 'success': success(message, options); break;
|
||||
case 'error': showError(message, options); break;
|
||||
case 'warning': warning(message, options); break;
|
||||
case 'info': info(message, options); break;
|
||||
}
|
||||
}
|
||||
|
||||
// Provide all the values to the context
|
||||
const contextValue: AppContextType = {
|
||||
// Game state
|
||||
games,
|
||||
isLoading,
|
||||
error,
|
||||
loadGames,
|
||||
|
||||
// DLC management
|
||||
dlcDialog,
|
||||
handleGameEdit,
|
||||
handleDlcDialogClose: closeDlcDialog,
|
||||
|
||||
// Game actions
|
||||
progressDialog,
|
||||
handleGameAction,
|
||||
handleDlcConfirm,
|
||||
|
||||
// Toast notifications
|
||||
showToast,
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onDismiss={removeToast} />
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
3
src/contexts/index.ts
Normal file
3
src/contexts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './AppContext';
|
||||
export * from './AppProvider';
|
||||
export * from './useAppContext';
|
||||
16
src/contexts/useAppContext.ts
Normal file
16
src/contexts/useAppContext.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useContext } from 'react'
|
||||
import { AppContext, AppContextType } from './AppContext'
|
||||
|
||||
/**
|
||||
* Custom hook to use the application context
|
||||
* Ensures proper error handling if used outside of AppProvider
|
||||
*/
|
||||
export const useAppContext = (): AppContextType => {
|
||||
const context = useContext(AppContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAppContext must be used within an AppProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
10
src/hooks/index.ts
Normal file
10
src/hooks/index.ts
Normal 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
119
src/hooks/useAppLogic.ts
Normal 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
295
src/hooks/useDlcManager.ts
Normal 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
221
src/hooks/useGameActions.ts
Normal 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
126
src/hooks/useGames.ts
Normal 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
94
src/hooks/useToasts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { AppProvider } from '@/contexts/index.ts'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
)
|
||||
@@ -2,16 +2,16 @@
|
||||
* Game image sources from Steam's CDN
|
||||
*/
|
||||
export const SteamImageType = {
|
||||
HEADER: 'header', // 460x215
|
||||
CAPSULE: 'capsule_616x353', // 616x353
|
||||
LOGO: 'logo', // Game logo with transparency
|
||||
LIBRARY_HERO: 'library_hero', // 1920x620
|
||||
HEADER: 'header', // 460x215
|
||||
CAPSULE: 'capsule_616x353', // 616x353
|
||||
LOGO: 'logo', // Game logo with transparency
|
||||
LIBRARY_HERO: 'library_hero', // 1920x620
|
||||
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
||||
} as const
|
||||
|
||||
export type SteamImageTypeKey = keyof typeof SteamImageType
|
||||
|
||||
// Cache for images to prevent flickering
|
||||
// Cache for images to prevent flickering during re-renders
|
||||
const imageCache: Map<string, string> = new Map()
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ const imageCache: Map<string, string> = new Map()
|
||||
export const getSteamImageUrl = (
|
||||
appId: string,
|
||||
type: (typeof SteamImageType)[SteamImageTypeKey]
|
||||
) => {
|
||||
): string => {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
|
||||
}
|
||||
|
||||
@@ -68,11 +68,16 @@ export const findBestGameImage = async (appId: string): Promise<string | null> =
|
||||
}
|
||||
|
||||
// Try these image types in order of preference
|
||||
const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
|
||||
const typesToTry = [
|
||||
SteamImageType.HEADER,
|
||||
SteamImageType.CAPSULE,
|
||||
SteamImageType.LIBRARY_CAPSULE
|
||||
]
|
||||
|
||||
for (const type of typesToTry) {
|
||||
const url = getSteamImageUrl(appId, type)
|
||||
const exists = await checkImageExists(url)
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
// Preload the image to prevent flickering
|
||||
@@ -88,6 +93,6 @@ export const findBestGameImage = async (appId: string): Promise<string | null> =
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached here, no valid image was found
|
||||
// If no valid image was found
|
||||
return null
|
||||
}
|
||||
}
|
||||
1
src/services/index.ts
Normal file
1
src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ImageService';
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
Font definitions
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src:
|
||||
url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||
src: url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||
font-weight: 400;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
|
||||
/*
|
||||
Layout styles
|
||||
Main layout structure for the application
|
||||
*/
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,67 +22,18 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
|
||||
background-image: radial-gradient(
|
||||
circle at 20% 30%,
|
||||
rgba(var(--primary-color), 0.05) 0%,
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
.app-header {
|
||||
@include flex-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
@include text-shadow;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cream-color),
|
||||
var(--primary-color),
|
||||
var(--smoke-color)
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Main content
|
||||
// Main content area
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -91,171 +43,109 @@
|
||||
z-index: var(--z-elevate);
|
||||
}
|
||||
|
||||
// Sidebar
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background-color: var(--secondary-bg);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
|
||||
padding: 1.5rem 1rem;
|
||||
@include flex-column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
z-index: var(--z-elevate) + 1;
|
||||
// Error message container
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(var(--danger), 0.05);
|
||||
border: 1px solid rgb(var(--danger), 0.2);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
details {
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
|
||||
// Game list container
|
||||
.game-list {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@include custom-scrollbar;
|
||||
position: relative;
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
p {
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-heavy);
|
||||
border: none;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.5rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-color), transparent);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game grid
|
||||
.game-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.5rem 2rem 0.5rem;
|
||||
scroll-behavior: smooth;
|
||||
align-items: stretch;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: fadeIn 0.5s forwards;
|
||||
}
|
||||
|
||||
// Loading and empty state
|
||||
.loading-indicator,
|
||||
.no-games-message {
|
||||
@include flex-center;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
// Error message styling in the game list
|
||||
.error-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
|
||||
background-color: rgba(var(--danger), 0.05);
|
||||
border: 1px solid rgb(var(--danger), 0.2);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
text-align: center;
|
||||
|
||||
.loading-indicator {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
h3 {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
animation: loading-shimmer 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include media-sm {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@include media-lg {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@include media-xl {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top button
|
||||
.scroll-top-button {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
|
||||
color: var(--text-primary);
|
||||
@include flex-center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
@include transition-standard;
|
||||
z-index: var(--z-header);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
to {
|
||||
left: 100%;
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@use './variables' as *;
|
||||
/*
|
||||
Mixins for reusable style patterns
|
||||
*/
|
||||
|
||||
// Basic flex helpers
|
||||
@mixin flex-center {
|
||||
@@ -83,7 +85,7 @@
|
||||
@mixin card {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
@include shadow;
|
||||
@include shadow-standard;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
@use './fonts' as *;
|
||||
/*
|
||||
CSS Reset and base styles
|
||||
*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
@use './fonts' as *;
|
||||
/*
|
||||
Variables for consistent styling
|
||||
*/
|
||||
|
||||
// Color palette
|
||||
:root {
|
||||
// Primary colors
|
||||
--primary-color: #ffc896;
|
||||
--secondary-color: #ffb278;
|
||||
|
||||
// Background
|
||||
--primary-bg: #0f0f0f;
|
||||
--secondary-bg: #151515;
|
||||
--tertiary-bg: #121212;
|
||||
--elevated-bg: #1a1a1a;
|
||||
--disabled: #5e5e5e;
|
||||
|
||||
// Text
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #c8c8c8;
|
||||
--text-soft: #afafaf;
|
||||
--text-heavy: #1a1a1a;
|
||||
--text-muted: #4b4b4b;
|
||||
|
||||
// Borders
|
||||
--border-dark: #1a1a1a;
|
||||
--border-soft: #282828;
|
||||
--border: #323232;
|
||||
|
||||
// Status colors
|
||||
--success: #8cc893;
|
||||
--warning: #ffc896;
|
||||
--danger: #d96b6b;
|
||||
--info: #80b4ff;
|
||||
|
||||
--success-light: #b0e0a9;
|
||||
--warning-light: #ffdcb9;
|
||||
--danger-light: #e69691;
|
||||
--info-light: #a8d2ff;
|
||||
|
||||
--success-soft: rgba(176, 224, 169, 0.15);
|
||||
--warning-soft: rgba(247, 200, 111, 0.15);
|
||||
--danger-soft: rgba(230, 150, 145, 0.15);
|
||||
--info-soft: rgba(168, 210, 255, 0.15);
|
||||
|
||||
// Feature colors
|
||||
--native: #8cc893;
|
||||
--proton: #ffc896;
|
||||
--cream: #80b4ff;
|
||||
--smoke: #fff096;
|
||||
|
||||
--modal-backdrop: rgba(30, 30, 30, 0.95);
|
||||
|
||||
// Animation durations
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 200ms;
|
||||
@@ -99,6 +53,7 @@
|
||||
--z-tooltip: 1500;
|
||||
}
|
||||
|
||||
// Color variables for SCSS usage
|
||||
$success-color: #55e07a;
|
||||
$danger-color: #ff5252;
|
||||
$primary-color: #4a76c4;
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
// Progress Dialog
|
||||
.progress-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(5px);
|
||||
@include flex-center;
|
||||
z-index: var(--z-modal);
|
||||
opacity: 0;
|
||||
animation: modal-appear 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-dialog {
|
||||
background-color: var(--elevated-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); // shadow-glow
|
||||
width: 450px;
|
||||
max-width: 90vw;
|
||||
border: 1px solid var(--border-soft);
|
||||
opacity: 0;
|
||||
cursor: default;
|
||||
|
||||
&.dialog-visible {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.with-instructions {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
// Instruction container in progress dialog
|
||||
.instruction-container {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
|
||||
h4 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-count {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background-color: var(--info-soft);
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--info);
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.command-box {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
font-family: monospace;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.command-box-smoke {
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.selectable-text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
cursor: text;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-button,
|
||||
.close-button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
@include transition-standard;
|
||||
border: none;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||
box-shadow: 0 6px 14px var(--info-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border);
|
||||
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Error message styling
|
||||
.error-message {
|
||||
@include flex-column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(var(--danger), 0.05);
|
||||
border: 1px solid rgb(var(--danger), 0.2);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
@include transition-standard;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for progress bar
|
||||
@keyframes progress-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.dlc-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(5px);
|
||||
@include flex-center;
|
||||
z-index: var(--z-modal);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
animation: modal-appear 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-selection-dialog {
|
||||
background-color: var(--elevated-bg);
|
||||
border-radius: 8px;
|
||||
width: 650px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: 0px 10px 25px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
|
||||
&.dialog-visible {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.2s var(--easing-bounce),
|
||||
opacity 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-dialog-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-game-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.game-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-count {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background-color: var(--info-soft);
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-dialog-search {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dlc-search-input {
|
||||
flex: 1;
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
@include transition-standard;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.select-all-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
|
||||
// Custom styling for the select all checkbox
|
||||
:global(.animated-checkbox) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:global(.checkbox-label) {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-loading-progress {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
.progress-bar-container {
|
||||
height: 6px;
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3);
|
||||
}
|
||||
|
||||
.loading-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.time-left {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dlc-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
@include transition-standard;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.dlc-item-loading {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loading-pulse {
|
||||
width: 70%;
|
||||
height: 20px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-soft) 0%,
|
||||
var(--border) 50%,
|
||||
var(--border-soft) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 4px;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Styling for the checkbox component inside dlc-item
|
||||
:global(.animated-checkbox) {
|
||||
width: 100%;
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox-sublabel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
// Optional hover effect
|
||||
&:hover {
|
||||
.checkbox-label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
border-color: var(--primary-color, #ffc896);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-loading {
|
||||
height: 200px;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.no-dlcs-message {
|
||||
height: 200px;
|
||||
@include flex-center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-dialog-actions {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.confirm-button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
@include transition-standard;
|
||||
border: none;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px var(--info-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Header
|
||||
.app-header {
|
||||
@include flex-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
@include text-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: var(--bold);
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
background-color: var(--border-dark);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.filter-list {
|
||||
list-style: none;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
li {
|
||||
@include transition-standard;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
|
||||
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom select dropdown styling
|
||||
.custom-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.select-selected {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
@include transition-standard;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 150px;
|
||||
|
||||
&:after {
|
||||
content: '⯆';
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-items {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 5px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
|
||||
&.show {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
@include transition-standard;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App logo styles
|
||||
.app-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--text-primary);
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip styles
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:hover .tooltip-content {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: var(--secondary-bg);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: var(--z-tooltip);
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: var(--secondary-bg) transparent transparent transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header controls
|
||||
.refresh-button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-heavy);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: var(--bold);
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
@include transition-standard;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
@include transition-standard;
|
||||
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(var(--primary-color), 0.3),
|
||||
inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
103
src/styles/components/buttons/_action_button.scss
Normal file
103
src/styles/components/buttons/_action_button.scss
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Action button styles
|
||||
Used specifically for game installation/uninstallation
|
||||
*/
|
||||
.action-button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: var(--bold);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&.install {
|
||||
background-color: var(--success);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.uninstall {
|
||||
background-color: var(--danger);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--disabled);
|
||||
transform: none;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
animation: button-loading 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button appearing on game cards
|
||||
.edit-button {
|
||||
padding: 0 0.7rem;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
font-weight: var(--bold);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for loading state
|
||||
@keyframes button-loading {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
/*
|
||||
Animated checkbox component styles
|
||||
*/
|
||||
.animated-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
159
src/styles/components/buttons/_button.scss
Normal file
159
src/styles/components/buttons/_button.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
Button component styles
|
||||
Core styling for buttons throughout the application
|
||||
*/
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
font-weight: var(--semibold);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
// Default states
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-standard);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: var(--shadow-standard) !important;
|
||||
}
|
||||
|
||||
// Sizing
|
||||
&.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
&.btn-md {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
font-size: 1rem;
|
||||
padding: 0.8rem 1.5rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
// Variants
|
||||
&.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background-color: var(--success);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--success), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background-color: var(--danger);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--danger), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-warning {
|
||||
background-color: var(--warning);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--warning-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--warning), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
&.btn-loading {
|
||||
position: relative;
|
||||
|
||||
.btn-spinner {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
.spinner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Icons
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.btn-icon-left {
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
&.btn-icon-right {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Full width
|
||||
&.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for spinner
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
167
src/styles/components/common/_loading.scss
Normal file
167
src/styles/components/common/_loading.scss
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Loading indicator component styles
|
||||
*/
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
// Size variations
|
||||
&.loading-small {
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 6px;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading-medium {
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading-large {
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
font-size: 1.1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 10px;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner styles
|
||||
.loading-spinner {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
// Loading dots animation
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
.dot {
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&.dot-1 {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
&.dot-2 {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar styles
|
||||
.loading-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
192
src/styles/components/dialogs/_dialog.scss
Normal file
192
src/styles/components/dialogs/_dialog.scss
Normal file
@@ -0,0 +1,192 @@
|
||||
@use '../../variables' as *;
|
||||
@use '../../mixins' as *;
|
||||
|
||||
/*
|
||||
DLC Selection Dialog styles
|
||||
For managing game DLCs
|
||||
*/
|
||||
|
||||
// DLC dialog search bar
|
||||
.dlc-dialog-search {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dlc-search-input {
|
||||
flex: 1;
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Select all container
|
||||
.select-all-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
|
||||
.animated-checkbox {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading progress indicator
|
||||
.dlc-loading-progress {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
.loading-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.time-left {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DLC list container
|
||||
.dlc-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
// DLC item
|
||||
.dlc-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.dlc-item-loading {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loading-pulse {
|
||||
width: 70%;
|
||||
height: 20px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-soft) 0%,
|
||||
var(--border) 50%,
|
||||
var(--border-soft) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 4px;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DLC loading state
|
||||
.dlc-loading {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.no-dlcs-message {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Game information in DLC dialog
|
||||
.dlc-game-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.game-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-count {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background-color: var(--info-soft);
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading animations
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
192
src/styles/components/dialogs/_dlc_dialog.scss
Normal file
192
src/styles/components/dialogs/_dlc_dialog.scss
Normal file
@@ -0,0 +1,192 @@
|
||||
@use '../../variables' as *;
|
||||
@use '../../mixins' as *;
|
||||
|
||||
/*
|
||||
DLC Selection Dialog styles
|
||||
For managing game DLCs
|
||||
*/
|
||||
|
||||
// DLC dialog search bar
|
||||
.dlc-dialog-search {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dlc-search-input {
|
||||
flex: 1;
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Select all container
|
||||
.select-all-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
|
||||
.animated-checkbox {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading progress indicator
|
||||
.dlc-loading-progress {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
.loading-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.time-left {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DLC list container
|
||||
.dlc-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
// DLC item
|
||||
.dlc-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.dlc-item-loading {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loading-pulse {
|
||||
width: 70%;
|
||||
height: 20px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-soft) 0%,
|
||||
var(--border) 50%,
|
||||
var(--border-soft) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 4px;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DLC loading state
|
||||
.dlc-loading {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.no-dlcs-message {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Game information in DLC dialog
|
||||
.dlc-game-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.game-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-count {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background-color: var(--info-soft);
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading animations
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
114
src/styles/components/dialogs/_progress_dialog.scss
Normal file
114
src/styles/components/dialogs/_progress_dialog.scss
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Progress dialog styles
|
||||
For installation/uninstallation progress display
|
||||
*/
|
||||
|
||||
// Progress bar
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
// Instruction container
|
||||
.instruction-container {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
|
||||
h4 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dlc-count {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background-color: var(--info-soft);
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--info);
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Command box
|
||||
.command-box {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
font-family: monospace;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.command-box-smoke {
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Selectable text
|
||||
.selectable-text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
cursor: text;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// Animation for progress bar
|
||||
@keyframes progress-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
/*
|
||||
Game card styles
|
||||
For game items displayed in the grid
|
||||
*/
|
||||
.game-item-card {
|
||||
position: relative;
|
||||
height: var(--card-height);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
will-change: opacity, transform;
|
||||
@include shadow-standard;
|
||||
@include transition-standard;
|
||||
box-shadow: var(--shadow-standard);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
transform-origin: center;
|
||||
|
||||
// Simple image loading animation
|
||||
@@ -19,7 +20,7 @@
|
||||
// Hover effects for the card
|
||||
.game-item-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
@include shadow-hover;
|
||||
box-shadow: var(--shadow-hover);
|
||||
z-index: 5;
|
||||
|
||||
.status-badge.native {
|
||||
@@ -41,18 +42,14 @@
|
||||
|
||||
// Special styling for cards with different statuses
|
||||
.game-item-card:has(.status-badge.cream) {
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(128, 181, 255, 0.15);
|
||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
|
||||
}
|
||||
|
||||
.game-item-card:has(.status-badge.smoke) {
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(255, 239, 150, 0.15);
|
||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
|
||||
}
|
||||
|
||||
// Simple clean overlay
|
||||
// Game item overlay
|
||||
.game-item-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -65,7 +62,8 @@
|
||||
rgba(0, 0, 0, 0.6) 50%,
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
);
|
||||
@include flex-column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
@@ -77,6 +75,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Game badges
|
||||
.game-badges {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -97,7 +96,7 @@
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
@include transition-standard;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -120,6 +119,7 @@
|
||||
color: var(--text-heavy);
|
||||
}
|
||||
|
||||
// Game title
|
||||
.game-title {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
@@ -140,6 +140,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Game actions
|
||||
.game-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -147,91 +148,7 @@
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: var(--bold);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
@include transition-standard;
|
||||
}
|
||||
|
||||
.action-button.install {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.action-button.install:hover {
|
||||
background-color: var(--success-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
|
||||
}
|
||||
|
||||
.action-button.uninstall {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
.action-button.uninstall:hover {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--disabled);
|
||||
transform: none;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-button:disabled::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
animation: button-loading 1.5s infinite;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
padding: 0 0.7rem;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
font-weight: var(--bold);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
@include transition-standard;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.edit-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// API not found message
|
||||
.api-not-found-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -288,9 +205,3 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes button-loading {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
150
src/styles/components/games/_gamelist.scss
Normal file
150
src/styles/components/games/_gamelist.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
@use '../../variables' as *;
|
||||
@use '../../mixins' as *;
|
||||
|
||||
/*
|
||||
Game list styles
|
||||
For game list container and grid
|
||||
*/
|
||||
.game-list {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@include custom-scrollbar;
|
||||
position: relative;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-color), transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game grid
|
||||
.game-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.5rem 2rem 0.5rem;
|
||||
scroll-behavior: smooth;
|
||||
align-items: stretch;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: fadeIn 0.5s forwards;
|
||||
}
|
||||
|
||||
// Loading and empty state
|
||||
.loading-indicator,
|
||||
.no-games-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
animation: loading-shimmer 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include media-sm {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@include media-lg {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@include media-xl {
|
||||
.game-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top button
|
||||
.scroll-top-button {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color),
|
||||
color-mix(in srgb, black 10%, var(--primary-color))
|
||||
);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
z-index: var(--z-header);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading shimmer animation
|
||||
@keyframes loading-shimmer {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
@use 'sass:color';
|
||||
|
||||
/*
|
||||
Animated background styles
|
||||
*/
|
||||
.animated-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
78
src/styles/components/layout/_header.scss
Normal file
78
src/styles/components/layout/_header.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Header component styles
|
||||
*/
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cream-color),
|
||||
var(--primary-color),
|
||||
var(--smoke-color)
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/*
|
||||
Initial loading screen styles
|
||||
*/
|
||||
.initial-loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
137
src/styles/components/layout/_sidebar.scss
Normal file
137
src/styles/components/layout/_sidebar.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
@use '../../variables' as *;
|
||||
@use '../../mixins' as *;
|
||||
|
||||
/*
|
||||
Sidebar component styles
|
||||
*/
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background-color: var(--secondary-bg);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
z-index: var(--z-elevate) + 1;
|
||||
@include custom-scrollbar;
|
||||
|
||||
h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
list-style: none;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
li {
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color),
|
||||
color-mix(in srgb, black 10%, var(--primary-color))
|
||||
);
|
||||
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom select dropdown styling
|
||||
.custom-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.select-selected {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 150px;
|
||||
|
||||
&:after {
|
||||
content: '⯆';
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-items {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 5px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
|
||||
&.show {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App logo styles
|
||||
.app-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--text-primary);
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
171
src/styles/components/notifications/_toast.scss
Normal file
171
src/styles/components/notifications/_toast.scss
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
Toast notification styles
|
||||
*/
|
||||
|
||||
// Toast container positioning
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: var(--z-tooltip);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 380px;
|
||||
|
||||
// Position variations
|
||||
&.top-right {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.top-center {
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.bottom-center {
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Individual toast styling
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background-color: var(--elevated-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.75rem 1rem;
|
||||
max-width: 100%;
|
||||
min-width: 280px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s var(--easing-ease-out);
|
||||
border-left: 4px solid;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Type-specific styling
|
||||
&.toast-success {
|
||||
border-color: var(--success);
|
||||
.toast-icon {
|
||||
color: var(--success);
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-error {
|
||||
border-color: var(--danger);
|
||||
.toast-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-warning {
|
||||
border-color: var(--warning);
|
||||
.toast-icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.toast-info {
|
||||
border-color: var(--info);
|
||||
.toast-icon {
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
|
||||
// Toast elements
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0; // Required for proper overflow handling
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations for toast
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,45 @@
|
||||
// Import variables and mixins first
|
||||
/*
|
||||
Main SCSS entry point
|
||||
Import all partials in the correct order
|
||||
*/
|
||||
|
||||
/* Basic variables and mixins */
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
@use './fonts' as *;
|
||||
|
||||
// Reset
|
||||
/* Reset & global styles */
|
||||
@use './reset';
|
||||
|
||||
// Layout
|
||||
@use './layout';
|
||||
|
||||
// Components
|
||||
@use './components/gamecard';
|
||||
@use './components/dialog';
|
||||
@use './components/background';
|
||||
@use './components/sidebar';
|
||||
@use './components/dlc_dialog';
|
||||
@use './components/loading_screen';
|
||||
@use './components/animated_checkbox';
|
||||
/* Layout components */
|
||||
@use 'components/layout/header';
|
||||
@use 'components/layout/sidebar';
|
||||
@use 'components/layout/background';
|
||||
@use 'components/layout/loading_screen';
|
||||
|
||||
/* Game components */
|
||||
@use 'components/games/gamecard';
|
||||
@use 'components/games/gamelist';
|
||||
|
||||
/* Button components */
|
||||
@use 'components/buttons/button';
|
||||
@use 'components/buttons/action_button';
|
||||
@use 'components/buttons/animated_checkbox';
|
||||
|
||||
/* Dialog components */
|
||||
@use 'components/dialogs/dialog';
|
||||
@use 'components/dialogs/progress_dialog';
|
||||
@use 'components/dialogs/dlc_dialog';
|
||||
|
||||
/* Notification components */
|
||||
@use 'components/notifications/toast';
|
||||
|
||||
/* Common components */
|
||||
@use 'components/common/loading';
|
||||
|
||||
/* Page-specific styles */
|
||||
//@use 'pages/home';
|
||||
|
||||
/* Theme */
|
||||
@use 'themes/dark';
|
||||
|
||||
27
src/styles/pages/_home.scss
Normal file
27
src/styles/pages/_home.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Home page specific styles
|
||||
*/
|
||||
|
||||
// Currently empty since most styles are component-based
|
||||
// Will be used for any specific home page layouts or adjustments
|
||||
|
||||
.home-page {
|
||||
// Page-specific styles can be added here
|
||||
}
|
||||
|
||||
// Page-specific media queries
|
||||
@include media-sm {
|
||||
// Small screen adjustments
|
||||
}
|
||||
|
||||
@include media-md {
|
||||
// Medium screen adjustments
|
||||
}
|
||||
|
||||
@include media-lg {
|
||||
// Large screen adjustments
|
||||
}
|
||||
|
||||
@include media-xl {
|
||||
// Extra large screen adjustments
|
||||
}
|
||||
53
src/styles/themes/_dark.scss
Normal file
53
src/styles/themes/_dark.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Dark theme styles
|
||||
Contains variables specific to dark theme
|
||||
*/
|
||||
|
||||
:root {
|
||||
// Primary colors
|
||||
--primary-color: #ffc896;
|
||||
--secondary-color: #ffb278;
|
||||
|
||||
// Background
|
||||
--primary-bg: #0f0f0f;
|
||||
--secondary-bg: #151515;
|
||||
--tertiary-bg: #121212;
|
||||
--elevated-bg: #1a1a1a;
|
||||
--disabled: #5e5e5e;
|
||||
|
||||
// Text
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #c8c8c8;
|
||||
--text-soft: #afafaf;
|
||||
--text-heavy: #1a1a1a;
|
||||
--text-muted: #4b4b4b;
|
||||
|
||||
// Borders
|
||||
--border-dark: #1a1a1a;
|
||||
--border-soft: #282828;
|
||||
--border: #323232;
|
||||
|
||||
// Status colors
|
||||
--success: #8cc893;
|
||||
--warning: #ffc896;
|
||||
--danger: #d96b6b;
|
||||
--info: #80b4ff;
|
||||
|
||||
--success-light: #b0e0a9;
|
||||
--warning-light: #ffdcb9;
|
||||
--danger-light: #e69691;
|
||||
--info-light: #a8d2ff;
|
||||
|
||||
--success-soft: rgba(176, 224, 169, 0.15);
|
||||
--warning-soft: rgba(247, 200, 111, 0.15);
|
||||
--danger-soft: rgba(230, 150, 145, 0.15);
|
||||
--info-soft: rgba(168, 210, 255, 0.15);
|
||||
|
||||
// Feature colors
|
||||
--native: #8cc893;
|
||||
--proton: #ffc896;
|
||||
--cream: #80b4ff;
|
||||
--smoke: #fff096;
|
||||
|
||||
--modal-backdrop: rgba(30, 30, 30, 0.95);
|
||||
}
|
||||
8
src/types/DlcInfo.ts
Normal file
8
src/types/DlcInfo.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* DLC information interface
|
||||
*/
|
||||
export interface DlcInfo {
|
||||
appid: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
14
src/types/Game.ts
Normal file
14
src/types/Game.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Game information interface
|
||||
*/
|
||||
export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
platform?: string;
|
||||
native: boolean;
|
||||
api_files: string[];
|
||||
cream_installed?: boolean;
|
||||
smoke_installed?: boolean;
|
||||
installing?: boolean;
|
||||
}
|
||||
2
src/types/index.ts
Normal file
2
src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Game'
|
||||
export * from './DlcInfo'
|
||||
85
src/utils/helpers.ts
Normal file
85
src/utils/helpers.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* General-purpose utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a timestamp in seconds to a human-readable string
|
||||
* @param seconds Number of seconds
|
||||
* @returns Formatted string (e.g., "5m 30s" or "30s")
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string if it exceeds the specified length
|
||||
* @param str String to truncate
|
||||
* @param maxLength Maximum length before truncation
|
||||
* @param suffix Suffix to append to truncated string (default: "...")
|
||||
* @returns Truncated string
|
||||
*/
|
||||
export function truncateString(str: string, maxLength: number, suffix: string = '...'): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.substring(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a function to limit how often it's called
|
||||
* @param fn Function to debounce
|
||||
* @param delay Delay in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
fn(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled function that only invokes the provided function at most once per specified interval
|
||||
* @param fn Function to throttle
|
||||
* @param limit Interval in milliseconds
|
||||
* @returns Throttled function
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastCall = 0;
|
||||
|
||||
return function(...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastCall < limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastCall = now;
|
||||
return fn(...args);
|
||||
};
|
||||
}
|
||||
1
src/utils/index.ts
Normal file
1
src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './helpers';
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [react()],
|
||||
|
||||
clearScreen: false,
|
||||
|
||||
Reference in New Issue
Block a user