mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-06 03:55:37 -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",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sass": "^1.89.0"
|
"sass": "^1.89.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
@@ -4247,6 +4248,19 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/varint": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sass": "^1.89.0"
|
"sass": "^1.89.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|||||||
@@ -1,178 +1,21 @@
|
|||||||
use crate::dlc_manager::DlcInfoWithState;
|
// This is a placeholder file - cache functionality has been removed
|
||||||
use log::{info, warn};
|
// and now only exists in memory within the App state
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
// Cache entry with timestamp for expiration
|
pub fn cache_dlcs(_game_id: &str, _dlcs: &[crate::dlc_manager::DlcInfoWithState]) -> std::io::Result<()> {
|
||||||
#[derive(Serialize, Deserialize)]
|
// This function is kept only for compatibility, but now does nothing
|
||||||
struct CacheEntry<T> {
|
// The DLCs are only cached in memory
|
||||||
data: T,
|
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||||
timestamp: u64, // Unix timestamp in seconds
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the cache directory
|
pub fn load_cached_dlcs(_game_id: &str) -> Option<Vec<crate::dlc_manager::DlcInfoWithState>> {
|
||||||
fn get_cache_dir() -> io::Result<PathBuf> {
|
// This function is kept only for compatibility, but now always returns None
|
||||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
|
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
None
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save data to cache file
|
pub fn clear_all_caches() -> std::io::Result<()> {
|
||||||
pub fn save_to_cache<T>(key: &str, data: &T, _ttl_hours: u64) -> io::Result<()>
|
// This function is kept only for compatibility, but now does nothing
|
||||||
where
|
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||||
T: Serialize + ?Sized,
|
Ok(())
|
||||||
{
|
}
|
||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
#![cfg_attr(
|
#![cfg_attr(
|
||||||
all(not(debug_assertions), target_os = "windows"),
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
mod cache;
|
|
||||||
mod dlc_manager;
|
mod dlc_manager;
|
||||||
mod installer;
|
mod installer;
|
||||||
mod searcher; // Keep the module for now
|
mod searcher;
|
||||||
|
|
||||||
use dlc_manager::DlcInfoWithState;
|
use dlc_manager::DlcInfoWithState;
|
||||||
use installer::{Game, InstallerAction, InstallerType};
|
use installer::{Game, InstallerAction, InstallerType};
|
||||||
@@ -19,529 +18,531 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use tokio::time::Duration;
|
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct GameAction {
|
pub struct GameAction {
|
||||||
game_id: String,
|
game_id: String,
|
||||||
action: String,
|
action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark fields with # to allow unused fields
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct DlcCache {
|
struct DlcCache {
|
||||||
data: Vec<DlcInfoWithState>,
|
#[allow(dead_code)]
|
||||||
timestamp: Instant,
|
data: Vec<DlcInfoWithState>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
timestamp: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structure to hold the state of installed games
|
// Structure to hold the state of installed games
|
||||||
struct AppState {
|
struct AppState {
|
||||||
games: Mutex<HashMap<String, Game>>,
|
games: Mutex<HashMap<String, Game>>,
|
||||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||||
fetch_cancellation: Arc<AtomicBool>,
|
fetch_cancellation: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
||||||
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
||||||
dlc_manager::get_all_dlcs(&game_path)
|
dlc_manager::get_all_dlcs(&game_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan and get the list of Steam games
|
// Scan and get the list of Steam games
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn scan_steam_games(
|
async fn scan_steam_games(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<Game>, String> {
|
) -> Result<Vec<Game>, String> {
|
||||||
info!("Starting Steam games scan");
|
info!("Starting Steam games scan");
|
||||||
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
||||||
|
|
||||||
// Get default Steam paths
|
// Get default Steam paths
|
||||||
let paths = searcher::get_default_steam_paths();
|
let paths = searcher::get_default_steam_paths();
|
||||||
|
|
||||||
// Find Steam libraries
|
// Find Steam libraries
|
||||||
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
|
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
|
||||||
let libraries = searcher::find_steam_libraries(&paths);
|
let libraries = searcher::find_steam_libraries(&paths);
|
||||||
|
|
||||||
// Group libraries by path to avoid duplicates in logs
|
// Group libraries by path to avoid duplicates in logs
|
||||||
let mut unique_libraries = std::collections::HashSet::new();
|
let mut unique_libraries = std::collections::HashSet::new();
|
||||||
for lib in &libraries {
|
for lib in &libraries {
|
||||||
unique_libraries.insert(lib.to_string_lossy().to_string());
|
unique_libraries.insert(lib.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Found {} Steam library directories:",
|
"Found {} Steam library directories:",
|
||||||
unique_libraries.len()
|
unique_libraries.len()
|
||||||
);
|
);
|
||||||
for (i, lib) in unique_libraries.iter().enumerate() {
|
for (i, lib) in unique_libraries.iter().enumerate() {
|
||||||
info!(" Library {}: {}", i + 1, lib);
|
info!(" Library {}: {}", i + 1, lib);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_scan_progress(
|
emit_scan_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&format!(
|
&format!(
|
||||||
"Found {} Steam libraries. Starting game scan...",
|
"Found {} Steam libraries. Starting game scan...",
|
||||||
unique_libraries.len()
|
unique_libraries.len()
|
||||||
),
|
),
|
||||||
20,
|
20,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find installed games
|
// Find installed games
|
||||||
let games_info = searcher::find_installed_games(&libraries).await;
|
let games_info = searcher::find_installed_games(&libraries).await;
|
||||||
|
|
||||||
emit_scan_progress(
|
emit_scan_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&format!("Found {} games. Processing...", games_info.len()),
|
&format!("Found {} games. Processing...", games_info.len()),
|
||||||
90,
|
90,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log summary of games found
|
// Log summary of games found
|
||||||
info!("Games scan complete - Found {} games", games_info.len());
|
info!("Games scan complete - Found {} games", games_info.len());
|
||||||
info!(
|
info!(
|
||||||
"Native games: {}",
|
"Native games: {}",
|
||||||
games_info.iter().filter(|g| g.native).count()
|
games_info.iter().filter(|g| g.native).count()
|
||||||
);
|
);
|
||||||
info!(
|
info!(
|
||||||
"Proton games: {}",
|
"Proton games: {}",
|
||||||
games_info.iter().filter(|g| !g.native).count()
|
games_info.iter().filter(|g| !g.native).count()
|
||||||
);
|
);
|
||||||
info!(
|
info!(
|
||||||
"Games with CreamLinux: {}",
|
"Games with CreamLinux: {}",
|
||||||
games_info.iter().filter(|g| g.cream_installed).count()
|
games_info.iter().filter(|g| g.cream_installed).count()
|
||||||
);
|
);
|
||||||
info!(
|
info!(
|
||||||
"Games with SmokeAPI: {}",
|
"Games with SmokeAPI: {}",
|
||||||
games_info.iter().filter(|g| g.smoke_installed).count()
|
games_info.iter().filter(|g| g.smoke_installed).count()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to our Game struct
|
// Convert to our Game struct
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
info!("Processing games into application state...");
|
info!("Processing games into application state...");
|
||||||
for game_info in games_info {
|
for game_info in games_info {
|
||||||
// Only log detailed game info at Debug level to keep Info logs cleaner
|
// Only log detailed game info at Debug level to keep Info logs cleaner
|
||||||
debug!(
|
debug!(
|
||||||
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
||||||
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
|
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
|
||||||
);
|
);
|
||||||
|
|
||||||
let game = Game {
|
let game = Game {
|
||||||
id: game_info.id,
|
id: game_info.id,
|
||||||
title: game_info.title,
|
title: game_info.title,
|
||||||
path: game_info.path.to_string_lossy().to_string(),
|
path: game_info.path.to_string_lossy().to_string(),
|
||||||
native: game_info.native,
|
native: game_info.native,
|
||||||
api_files: game_info.api_files,
|
api_files: game_info.api_files,
|
||||||
cream_installed: game_info.cream_installed,
|
cream_installed: game_info.cream_installed,
|
||||||
smoke_installed: game_info.smoke_installed,
|
smoke_installed: game_info.smoke_installed,
|
||||||
installing: false,
|
installing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
result.push(game.clone());
|
result.push(game.clone());
|
||||||
|
|
||||||
// Store in state for later use
|
// Store in state for later use
|
||||||
state.games.lock().insert(game.id.clone(), game);
|
state.games.lock().insert(game.id.clone(), game);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_scan_progress(
|
emit_scan_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&format!("Scan complete. Found {} games.", result.len()),
|
&format!("Scan complete. Found {} games.", result.len()),
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("Game scan completed successfully");
|
info!("Game scan completed successfully");
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to emit scan progress events
|
// Helper function to emit scan progress events
|
||||||
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
|
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
|
||||||
// Log first, then emit the event
|
// Log first, then emit the event
|
||||||
info!("Scan progress: {}% - {}", progress, message);
|
info!("Scan progress: {}% - {}", progress, message);
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"message": message,
|
"message": message,
|
||||||
"progress": progress
|
"progress": progress
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
||||||
warn!("Failed to emit scan-progress event: {}", e);
|
warn!("Failed to emit scan-progress event: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch game info by ID - useful for single game updates
|
// Fetch game info by ID - useful for single game updates
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
||||||
let games = state.games.lock();
|
let games = state.games.lock();
|
||||||
games
|
games
|
||||||
.get(&game_id)
|
.get(&game_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified action handler for installation and uninstallation
|
// Unified action handler for installation and uninstallation
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn process_game_action(
|
async fn process_game_action(
|
||||||
game_action: GameAction,
|
game_action: GameAction,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Game, String> {
|
) -> Result<Game, String> {
|
||||||
// Clone the information we need from state to avoid lifetime issues
|
// Clone the information we need from state to avoid lifetime issues
|
||||||
let game = {
|
let game = {
|
||||||
let games = state.games.lock();
|
let games = state.games.lock();
|
||||||
games
|
games
|
||||||
.get(&game_action.game_id)
|
.get(&game_action.game_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse the action string to determine type and operation
|
// Parse the action string to determine type and operation
|
||||||
let (installer_type, action) = match game_action.action.as_str() {
|
let (installer_type, action) = match game_action.action.as_str() {
|
||||||
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
||||||
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
||||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||||
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute the action
|
// Execute the action
|
||||||
installer::process_action(
|
installer::process_action(
|
||||||
game_action.game_id.clone(),
|
game_action.game_id.clone(),
|
||||||
installer_type,
|
installer_type,
|
||||||
action,
|
action,
|
||||||
game.clone(),
|
game.clone(),
|
||||||
app_handle.clone(),
|
app_handle.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update game status in state based on the action
|
// Update game status in state based on the action
|
||||||
let updated_game = {
|
let updated_game = {
|
||||||
let mut games_map = state.games.lock();
|
let mut games_map = state.games.lock();
|
||||||
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
|
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"Game with ID {} not found after action",
|
"Game with ID {} not found after action",
|
||||||
game_action.game_id
|
game_action.game_id
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Update installation status
|
// Update installation status
|
||||||
match (installer_type, action) {
|
match (installer_type, action) {
|
||||||
(InstallerType::Cream, InstallerAction::Install) => {
|
(InstallerType::Cream, InstallerAction::Install) => {
|
||||||
game.cream_installed = true;
|
game.cream_installed = true;
|
||||||
}
|
}
|
||||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||||
game.cream_installed = false;
|
game.cream_installed = false;
|
||||||
}
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||||
game.smoke_installed = true;
|
game.smoke_installed = true;
|
||||||
}
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||||
game.smoke_installed = false;
|
game.smoke_installed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset installing flag
|
// Reset installing flag
|
||||||
game.installing = false;
|
game.installing = false;
|
||||||
|
|
||||||
// Return updated game info
|
// Return updated game info
|
||||||
game.clone()
|
game.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit an event to update the UI for this specific game
|
// Emit an event to update the UI for this specific game
|
||||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||||
warn!("Failed to emit game-updated event: {}", e);
|
warn!("Failed to emit game-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(updated_game)
|
Ok(updated_game)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch DLC list for a game
|
// Fetch DLC list for a game
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn fetch_game_dlcs(
|
async fn fetch_game_dlcs(
|
||||||
game_id: String,
|
game_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<DlcInfoWithState>, String> {
|
) -> Result<Vec<DlcInfoWithState>, String> {
|
||||||
info!("Fetching DLCs for game ID: {}", game_id);
|
info!("Fetching DLCs for game ID: {}", game_id);
|
||||||
|
|
||||||
// Fetch DLC data
|
// Fetch DLC data
|
||||||
match installer::fetch_dlc_details(&game_id).await {
|
match installer::fetch_dlc_details(&game_id).await {
|
||||||
Ok(dlcs) => {
|
Ok(dlcs) => {
|
||||||
// Convert to DlcInfoWithState
|
// Convert to DlcInfoWithState
|
||||||
let dlcs_with_state = dlcs
|
let dlcs_with_state = dlcs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|dlc| DlcInfoWithState {
|
.map(|dlc| DlcInfoWithState {
|
||||||
appid: dlc.appid,
|
appid: dlc.appid,
|
||||||
name: dlc.name,
|
name: dlc.name,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Cache in memory for this session (but not on disk)
|
// Cache in memory for this session (but not on disk)
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
let mut cache = state.dlc_cache.lock();
|
let mut cache = state.dlc_cache.lock();
|
||||||
cache.insert(
|
cache.insert(
|
||||||
game_id.clone(),
|
game_id.clone(),
|
||||||
DlcCache {
|
DlcCache {
|
||||||
data: dlcs_with_state.clone(),
|
data: dlcs_with_state.clone(),
|
||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(dlcs_with_state)
|
Ok(dlcs_with_state)
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
|
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
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>();
|
let state = app_handle.state::<AppState>();
|
||||||
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
// Reset after a short delay
|
// Reset after a short delay
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch DLC list with progress updates (streaming)
|
// Fetch DLC list with progress updates (streaming)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
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
|
// Fetch DLC data from API
|
||||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||||
Ok(dlcs) => {
|
Ok(dlcs) => {
|
||||||
info!(
|
info!(
|
||||||
"Successfully streamed {} DLCs for game {}",
|
"Successfully streamed {} DLCs for game {}",
|
||||||
dlcs.len(),
|
dlcs.len(),
|
||||||
game_id
|
game_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DLCInfoWithState for in-memory caching only
|
// Convert to DLCInfoWithState for in-memory caching only
|
||||||
let dlcs_with_state = dlcs
|
let dlcs_with_state = dlcs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|dlc| DlcInfoWithState {
|
.map(|dlc| DlcInfoWithState {
|
||||||
appid: dlc.appid,
|
appid: dlc.appid,
|
||||||
name: dlc.name,
|
name: dlc.name,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Update in-memory cache without storing to disk
|
// Update in-memory cache without storing to disk
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
let mut dlc_cache = state.dlc_cache.lock();
|
let mut dlc_cache = state.dlc_cache.lock();
|
||||||
dlc_cache.insert(
|
dlc_cache.insert(
|
||||||
game_id.clone(),
|
game_id.clone(),
|
||||||
DlcCache {
|
DlcCache {
|
||||||
data: dlcs_with_state,
|
data: dlcs_with_state,
|
||||||
timestamp: tokio::time::Instant::now(),
|
timestamp: tokio::time::Instant::now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to stream DLC details: {}", e);
|
error!("Failed to stream DLC details: {}", e);
|
||||||
// Emit error event
|
// Emit error event
|
||||||
let error_payload = serde_json::json!({
|
let error_payload = serde_json::json!({
|
||||||
"error": format!("Failed to fetch DLC details: {}", e)
|
"error": format!("Failed to fetch DLC details: {}", e)
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
||||||
warn!("Failed to emit dlc-error event: {}", emit_err);
|
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
|
// Clear caches command renamed to flush_data for clarity
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn clear_caches() -> Result<(), String> {
|
fn clear_caches() -> Result<(), String> {
|
||||||
info!("Data flush requested - cleaning in-memory state only");
|
info!("Data flush requested - cleaning in-memory state only");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the list of enabled DLCs for a game
|
// Get the list of enabled DLCs for a game
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
|
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
|
||||||
info!("Getting enabled DLCs for: {}", game_path);
|
info!("Getting enabled DLCs for: {}", game_path);
|
||||||
dlc_manager::get_enabled_dlcs(&game_path)
|
dlc_manager::get_enabled_dlcs(&game_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the DLC configuration for a game
|
// Update the DLC configuration for a game
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_dlc_configuration_command(
|
fn update_dlc_configuration_command(
|
||||||
game_path: String,
|
game_path: String,
|
||||||
dlcs: Vec<DlcInfoWithState>,
|
dlcs: Vec<DlcInfoWithState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
info!("Updating DLC configuration for: {}", game_path);
|
info!("Updating DLC configuration for: {}", game_path);
|
||||||
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install CreamLinux with selected DLCs
|
// Install CreamLinux with selected DLCs
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn install_cream_with_dlcs_command(
|
async fn install_cream_with_dlcs_command(
|
||||||
game_id: String,
|
game_id: String,
|
||||||
selected_dlcs: Vec<DlcInfoWithState>,
|
selected_dlcs: Vec<DlcInfoWithState>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Game, String> {
|
) -> Result<Game, String> {
|
||||||
info!(
|
info!(
|
||||||
"Installing CreamLinux with selected DLCs for game: {}",
|
"Installing CreamLinux with selected DLCs for game: {}",
|
||||||
game_id
|
game_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clone selected_dlcs for later use
|
// Clone selected_dlcs for later use
|
||||||
let selected_dlcs_clone = selected_dlcs.clone();
|
let selected_dlcs_clone = selected_dlcs.clone();
|
||||||
|
|
||||||
// Install CreamLinux with the selected DLCs
|
// Install CreamLinux with the selected DLCs
|
||||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
|
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Return updated game info
|
// Return updated game info
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
|
|
||||||
// Get a mutable reference and update the game
|
// Get a mutable reference and update the game
|
||||||
let game = {
|
let game = {
|
||||||
let mut games_map = state.games.lock();
|
let mut games_map = state.games.lock();
|
||||||
let game = games_map.get_mut(&game_id).ok_or_else(|| {
|
let game = games_map.get_mut(&game_id).ok_or_else(|| {
|
||||||
format!("Game with ID {} not found after installation", game_id)
|
format!("Game with ID {} not found after installation", game_id)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Update installation status
|
// Update installation status
|
||||||
game.cream_installed = true;
|
game.cream_installed = true;
|
||||||
game.installing = false;
|
game.installing = false;
|
||||||
|
|
||||||
// Clone the game for returning later
|
// Clone the game for returning later
|
||||||
game.clone()
|
game.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit an event to update the UI
|
// Emit an event to update the UI
|
||||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||||
warn!("Failed to emit game-updated event: {}", e);
|
warn!("Failed to emit game-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show installation complete dialog with instructions
|
// Show installation complete dialog with instructions
|
||||||
let instructions = installer::InstallationInstructions {
|
let instructions = installer::InstallationInstructions {
|
||||||
type_: "cream_install".to_string(),
|
type_: "cream_install".to_string(),
|
||||||
command: "sh ./cream.sh %command%".to_string(),
|
command: "sh ./cream.sh %command%".to_string(),
|
||||||
game_title: game.title.clone(),
|
game_title: game.title.clone(),
|
||||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
|
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
|
||||||
};
|
};
|
||||||
|
|
||||||
installer::emit_progress(
|
installer::emit_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&format!("Installation Completed: {}", game.title),
|
&format!("Installation Completed: {}", game.title),
|
||||||
"CreamLinux has been installed successfully!",
|
"CreamLinux has been installed successfully!",
|
||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions),
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(game)
|
Ok(game)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"Failed to install CreamLinux with selected DLCs: {}",
|
"Failed to install CreamLinux with selected DLCs: {}",
|
||||||
e
|
e
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup logging
|
// Setup logging
|
||||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use log4rs::append::file::FileAppender;
|
use log4rs::append::file::FileAppender;
|
||||||
use log4rs::config::{Appender, Config, Root};
|
use log4rs::config::{Appender, Config, Root};
|
||||||
use log4rs::encode::pattern::PatternEncoder;
|
use log4rs::encode::pattern::PatternEncoder;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
// Get XDG cache directory
|
// Get XDG cache directory
|
||||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
||||||
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
||||||
|
|
||||||
// Clear the log file on startup
|
// Clear the log file on startup
|
||||||
if log_path.exists() {
|
if log_path.exists() {
|
||||||
if let Err(e) = fs::write(&log_path, "") {
|
if let Err(e) = fs::write(&log_path, "") {
|
||||||
eprintln!("Warning: Failed to clear log file: {}", e);
|
eprintln!("Warning: Failed to clear log file: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a file appender
|
// Create a file appender
|
||||||
let file = FileAppender::builder()
|
let file = FileAppender::builder()
|
||||||
.encoder(Box::new(PatternEncoder::new(
|
.encoder(Box::new(PatternEncoder::new(
|
||||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
||||||
)))
|
)))
|
||||||
.build(log_path)?;
|
.build(log_path)?;
|
||||||
|
|
||||||
// Build the config
|
// Build the config
|
||||||
let config = Config::builder()
|
let config = Config::builder()
|
||||||
.appender(Appender::builder().build("file", Box::new(file)))
|
.appender(Appender::builder().build("file", Box::new(file)))
|
||||||
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
||||||
|
|
||||||
// Initialize log4rs with this config
|
// Initialize log4rs with this config
|
||||||
log4rs::init_config(config)?;
|
log4rs::init_config(config)?;
|
||||||
|
|
||||||
info!("CreamLinux started with a clean log file");
|
info!("CreamLinux started with a clean log file");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Set up logging first
|
// Set up logging first
|
||||||
if let Err(e) = setup_logging() {
|
if let Err(e) = setup_logging() {
|
||||||
eprintln!("Warning: Failed to initialize logging: {}", e);
|
eprintln!("Warning: Failed to initialize logging: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Initializing CreamLinux application");
|
info!("Initializing CreamLinux application");
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
games: Mutex::new(HashMap::new()),
|
games: Mutex::new(HashMap::new()),
|
||||||
dlc_cache: Mutex::new(HashMap::new()),
|
dlc_cache: Mutex::new(HashMap::new()),
|
||||||
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
scan_steam_games,
|
scan_steam_games,
|
||||||
get_game_info,
|
get_game_info,
|
||||||
process_game_action,
|
process_game_action,
|
||||||
fetch_game_dlcs,
|
fetch_game_dlcs,
|
||||||
stream_game_dlcs,
|
stream_game_dlcs,
|
||||||
get_enabled_dlcs_command,
|
get_enabled_dlcs_command,
|
||||||
update_dlc_configuration_command,
|
update_dlc_configuration_command,
|
||||||
install_cream_with_dlcs_command,
|
install_cream_with_dlcs_command,
|
||||||
get_all_dlcs_command,
|
get_all_dlcs_command,
|
||||||
clear_caches,
|
clear_caches,
|
||||||
abort_dlc_fetch,
|
abort_dlc_fetch,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Add a setup handler to do any initialization work
|
// Add a setup handler to do any initialization work
|
||||||
info!("Tauri application setup");
|
info!("Tauri application setup");
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
window.open_devtools();
|
window.open_devtools();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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 { useAppContext } from '@/contexts/useAppContext'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { useAppLogic } from '@/hooks'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
|
||||||
import './styles/main.scss'
|
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
|
// Layout components
|
||||||
interface Game {
|
import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary } from '@/components/layout'
|
||||||
id: string
|
import AnimatedBackground from '@/components/layout/AnimatedBackground'
|
||||||
title: string
|
|
||||||
path: string
|
|
||||||
native: boolean
|
|
||||||
platform?: string
|
|
||||||
api_files: string[]
|
|
||||||
cream_installed?: boolean
|
|
||||||
smoke_installed?: boolean
|
|
||||||
installing?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for installation instructions
|
// Dialog components
|
||||||
interface InstructionInfo {
|
import { ProgressDialog, DlcSelectionDialog } from '@/components/dialogs'
|
||||||
type: string
|
|
||||||
command: string
|
|
||||||
game_title: string
|
|
||||||
dlc_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for DLC information
|
// Game components
|
||||||
interface DlcInfo {
|
import { GameList } from '@/components/games'
|
||||||
appid: string
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application component
|
||||||
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const [games, setGames] = useState<Game[]>([])
|
// Get application logic from hook
|
||||||
const [filter, setFilter] = useState('all')
|
const {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
filter,
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
setFilter,
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
searchQuery,
|
||||||
const [scanProgress, setScanProgress] = useState({
|
handleSearchChange,
|
||||||
message: 'Initializing...',
|
isInitialLoad,
|
||||||
progress: 0,
|
scanProgress,
|
||||||
})
|
filteredGames,
|
||||||
const [error, setError] = useState<string | null>(null)
|
handleRefresh,
|
||||||
const refreshInProgress = useRef(false)
|
isLoading,
|
||||||
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false)
|
error
|
||||||
const dlcFetchController = useRef<AbortController | null>(null)
|
} = useAppLogic({ autoLoad: true })
|
||||||
const activeDlcFetchId = useRef<string | null>(null)
|
|
||||||
|
// Get action handlers from context
|
||||||
// Progress dialog state
|
const {
|
||||||
const [progressDialog, setProgressDialog] = useState({
|
dlcDialog,
|
||||||
visible: false,
|
handleDlcDialogClose,
|
||||||
title: '',
|
progressDialog,
|
||||||
message: '',
|
handleGameAction,
|
||||||
progress: 0,
|
handleDlcConfirm,
|
||||||
showInstructions: false,
|
handleGameEdit
|
||||||
instructions: undefined as InstructionInfo | undefined,
|
} = useAppContext()
|
||||||
})
|
|
||||||
|
// Show loading screen during initial load
|
||||||
// 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
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
|
return <InitialLoadingScreen
|
||||||
|
message={scanProgress.message}
|
||||||
|
progress={scanProgress.progress}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<ErrorBoundary>
|
||||||
{/* Animated background */}
|
<div className="app-container">
|
||||||
<AnimatedBackground />
|
{/* Animated background */}
|
||||||
|
<AnimatedBackground />
|
||||||
|
|
||||||
<Header onRefresh={loadGames} onSearch={handleSearchChange} searchQuery={searchQuery} />
|
{/* Header with search */}
|
||||||
<div className="main-content">
|
<Header
|
||||||
<Sidebar setFilter={setFilter} currentFilter={filter} />
|
onRefresh={handleRefresh}
|
||||||
{error ? (
|
onSearch={handleSearchChange}
|
||||||
<div className="error-message">
|
searchQuery={searchQuery}
|
||||||
<h3>Error Loading Games</h3>
|
refreshDisabled={isLoading}
|
||||||
<p>{error}</p>
|
/>
|
||||||
<button onClick={loadGames}>Retry</button>
|
|
||||||
</div>
|
<div className="main-content">
|
||||||
) : (
|
{/* Sidebar for filtering */}
|
||||||
<GameList
|
<Sidebar setFilter={setFilter} currentFilter={filter} />
|
||||||
games={filteredGames}
|
|
||||||
isLoading={isLoading}
|
{/* Show error or game list */}
|
||||||
onAction={handleGameAction}
|
{error ? (
|
||||||
onEdit={handleGameEdit}
|
<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>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
{/* 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
interface AnimatedCheckboxProps {
|
||||||
checked: boolean
|
checked: boolean;
|
||||||
onChange: () => void
|
onChange: () => void;
|
||||||
label?: string
|
label?: string;
|
||||||
sublabel?: string
|
sublabel?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
/**
|
||||||
|
* Animated checkbox component with optional label and sublabel
|
||||||
|
*/
|
||||||
|
const AnimatedCheckbox = ({
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
sublabel,
|
sublabel,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}: AnimatedCheckboxProps) => {
|
||||||
return (
|
return (
|
||||||
<label className={`animated-checkbox ${className}`}>
|
<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' : ''}`}>
|
<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
|
<path
|
||||||
className={`checkmark ${checked ? 'checked' : ''}`}
|
className={`checkmark ${checked ? 'checked' : ''}`}
|
||||||
d="M5 12l5 5L20 7"
|
d="M5 12l5 5L20 7"
|
||||||
@@ -31,6 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{(label || sublabel) && (
|
{(label || sublabel) && (
|
||||||
<div className="checkbox-content">
|
<div className="checkbox-content">
|
||||||
{label && <span className="checkbox-label">{label}</span>}
|
{label && <span className="checkbox-label">{label}</span>}
|
||||||
@@ -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 {
|
export interface InstallationInstructions {
|
||||||
type: string
|
type: string;
|
||||||
command: string
|
command: string;
|
||||||
game_title: string
|
game_title: string;
|
||||||
dlc_count?: number
|
dlc_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressDialogProps {
|
export interface ProgressDialogProps {
|
||||||
title: string
|
visible: boolean;
|
||||||
message: string
|
title: string;
|
||||||
progress: number // 0-100
|
message: string;
|
||||||
visible: boolean
|
progress: number;
|
||||||
showInstructions?: boolean
|
showInstructions?: boolean;
|
||||||
instructions?: InstructionInfo
|
instructions?: InstallationInstructions;
|
||||||
onClose?: () => void
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
/**
|
||||||
|
* ProgressDialog component
|
||||||
|
* Shows installation progress with a progress bar and optional instructions
|
||||||
|
*/
|
||||||
|
const ProgressDialog = ({
|
||||||
|
visible,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
progress,
|
progress,
|
||||||
visible,
|
|
||||||
showInstructions = false,
|
showInstructions = false,
|
||||||
instructions,
|
instructions,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}: ProgressDialogProps) => {
|
||||||
const [copySuccess, setCopySuccess] = useState(false)
|
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 = () => {
|
const handleCopyCommand = () => {
|
||||||
if (instructions?.command) {
|
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)
|
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
||||||
const showCopyButton =
|
const showCopyButton =
|
||||||
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
||||||
@@ -147,14 +117,17 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
const isCloseButtonEnabled = showInstructions || progress >= 100
|
const isCloseButtonEnabled = showInstructions || progress >= 100
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
visible={visible}
|
||||||
onClick={handleOverlayClick}
|
onClose={isCloseButtonEnabled ? onClose : undefined}
|
||||||
|
size="medium"
|
||||||
|
preventBackdropClose={!isCloseButtonEnabled}
|
||||||
>
|
>
|
||||||
<div
|
<DialogHeader>
|
||||||
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
|
|
||||||
>
|
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
|
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
@@ -174,36 +147,34 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
<div className={getCommandBoxClass()}>
|
<div className={getCommandBoxClass()}>
|
||||||
<pre className="selectable-text">{instructions.command}</pre>
|
<pre className="selectable-text">{instructions.command}</pre>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
{showInstructions && showCopyButton && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCopyCommand}
|
||||||
|
>
|
||||||
|
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show close button even if no instructions */}
|
{isCloseButtonEnabled && (
|
||||||
{!showInstructions && progress >= 100 && (
|
<Button
|
||||||
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
variant="secondary"
|
||||||
<button className="close-button" onClick={handleClose}>
|
onClick={onClose}
|
||||||
|
disabled={!isCloseButtonEnabled}
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</DialogActions>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</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 { useState, useEffect } from 'react'
|
||||||
import { findBestGameImage } from '../services/ImageService'
|
import { findBestGameImage } from '@/services/ImageService'
|
||||||
import { ActionType } from './ActionButton'
|
import { Game } from '@/types'
|
||||||
|
import { ActionButton, ActionType, Button } from '@/components/buttons'
|
||||||
interface Game {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
path: string
|
|
||||||
platform?: string
|
|
||||||
native: boolean
|
|
||||||
api_files: string[]
|
|
||||||
cream_installed?: boolean
|
|
||||||
smoke_installed?: boolean
|
|
||||||
installing?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameItemProps {
|
interface GameItemProps {
|
||||||
game: Game
|
game: Game
|
||||||
@@ -20,7 +9,11 @@ interface GameItemProps {
|
|||||||
onEdit?: (gameId: string) => void
|
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 [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
@@ -116,58 +109,51 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
|||||||
<div className="game-actions">
|
<div className="game-actions">
|
||||||
{/* Show CreamLinux button only for native games */}
|
{/* Show CreamLinux button only for native games */}
|
||||||
{shouldShowCream && (
|
{shouldShowCream && (
|
||||||
<button
|
<ActionButton
|
||||||
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
|
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
||||||
|
isInstalled={!!game.cream_installed}
|
||||||
|
isWorking={!!game.installing}
|
||||||
onClick={handleCreamAction}
|
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 */}
|
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||||
{shouldShowSmoke && (
|
{shouldShowSmoke && (
|
||||||
<button
|
<ActionButton
|
||||||
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
|
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||||
|
isInstalled={!!game.smoke_installed}
|
||||||
|
isWorking={!!game.installing}
|
||||||
onClick={handleSmokeAction}
|
onClick={handleSmokeAction}
|
||||||
disabled={!!game.installing}
|
/>
|
||||||
>
|
|
||||||
{game.installing
|
|
||||||
? 'Working...'
|
|
||||||
: game.smoke_installed
|
|
||||||
? 'Uninstall SmokeAPI'
|
|
||||||
: 'Install SmokeAPI'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show message for Proton games without API files */}
|
{/* Show message for Proton games without API files */}
|
||||||
{isProtonNoApi && (
|
{isProtonNoApi && (
|
||||||
<div className="api-not-found-message">
|
<div className="api-not-found-message">
|
||||||
<span>Steam API DLL not found</span>
|
<span>Steam API DLL not found</span>
|
||||||
<button
|
<Button
|
||||||
className="rescan-button"
|
variant="warning"
|
||||||
|
size="small"
|
||||||
onClick={() => onAction(game.id, 'install_smoke')}
|
onClick={() => onAction(game.id, 'install_smoke')}
|
||||||
title="Attempt to scan again"
|
title="Attempt to scan again"
|
||||||
>
|
>
|
||||||
Rescan
|
Rescan
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||||
{game.cream_installed && (
|
{game.cream_installed && (
|
||||||
<button
|
<Button
|
||||||
className="edit-button"
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={!game.cream_installed || !!game.installing}
|
disabled={!game.cream_installed || !!game.installing}
|
||||||
title="Manage DLCs"
|
title="Manage DLCs"
|
||||||
|
className="edit-button"
|
||||||
>
|
>
|
||||||
Manage DLCs
|
Manage DLCs
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 { useState, useEffect, useMemo } from 'react'
|
||||||
import GameItem from './GameItem'
|
import {GameItem, ImagePreloader} from '@/components/games'
|
||||||
import ImagePreloader from './ImagePreloader'
|
import { ActionType } from '@/components/buttons'
|
||||||
import { ActionType } from './ActionButton'
|
import { Game } from '@/types'
|
||||||
|
import LoadingIndicator from '../common/LoadingIndicator'
|
||||||
interface Game {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
path: string
|
|
||||||
platform?: string
|
|
||||||
native: boolean
|
|
||||||
api_files: string[]
|
|
||||||
cream_installed?: boolean
|
|
||||||
smoke_installed?: boolean
|
|
||||||
installing?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameListProps {
|
interface GameListProps {
|
||||||
games: Game[]
|
games: Game[]
|
||||||
@@ -22,10 +11,14 @@ interface GameListProps {
|
|||||||
onEdit?: (gameId: string) => void
|
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)
|
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(() => {
|
const sortedGames = useMemo(() => {
|
||||||
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||||
}, [games])
|
}, [games])
|
||||||
@@ -35,25 +28,22 @@ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit
|
|||||||
setImagesPreloaded(false)
|
setImagesPreloaded(false)
|
||||||
}, [games])
|
}, [games])
|
||||||
|
|
||||||
// Debug log to help diagnose game states
|
const handlePreloadComplete = () => {
|
||||||
useEffect(() => {
|
setImagesPreloaded(true)
|
||||||
if (games.length > 0) {
|
}
|
||||||
console.log('Games state in GameList:', games.length, 'games')
|
|
||||||
}
|
|
||||||
}, [games])
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="game-list">
|
<div className="game-list">
|
||||||
<div className="loading-indicator">Scanning for games...</div>
|
<LoadingIndicator
|
||||||
|
type="spinner"
|
||||||
|
size="large"
|
||||||
|
message="Scanning for games..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreloadComplete = () => {
|
|
||||||
setImagesPreloaded(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-list">
|
<div className="game-list">
|
||||||
<h2>Games ({games.length})</h2>
|
<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)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,7 +36,7 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color palette
|
// Color palette matching our theme
|
||||||
const colors = [
|
const colors = [
|
||||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||||
'rgba(155, 125, 255, 0.5)', // purple
|
'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.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
|
||||||
// Connect particles
|
// Connect particles that are close to each other
|
||||||
particles.forEach((otherParticle) => {
|
particles.forEach((otherParticle) => {
|
||||||
const dx = particle.x - otherParticle.x
|
const dx = particle.x - otherParticle.x
|
||||||
const dy = particle.y - otherParticle.y
|
const dy = particle.y - otherParticle.y
|
||||||
@@ -100,6 +103,7 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
// Start animation
|
// Start animation
|
||||||
animate()
|
animate()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', setCanvasSize)
|
window.removeEventListener('resize', setCanvasSize)
|
||||||
}
|
}
|
||||||
@@ -109,18 +113,9 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="animated-background"
|
className="animated-background"
|
||||||
style={{
|
aria-hidden="true"
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 0,
|
|
||||||
opacity: 0.4,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
interface HeaderProps {
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -7,19 +7,28 @@ interface HeaderProps {
|
|||||||
searchQuery: string
|
searchQuery: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({
|
/**
|
||||||
|
* Application header component
|
||||||
|
* Contains the app title, search input, and refresh button
|
||||||
|
*/
|
||||||
|
const Header = ({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshDisabled = false,
|
refreshDisabled = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
}) => {
|
}: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>CreamLinux</h1>
|
<h1>CreamLinux</h1>
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={refreshDisabled}
|
||||||
|
className="refresh-button"
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search games..."
|
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 {
|
interface InitialLoadingScreenProps {
|
||||||
message: string
|
message: string;
|
||||||
progress: number
|
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 (
|
return (
|
||||||
<div className="initial-loading-screen">
|
<div className="initial-loading-screen">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
<h1>CreamLinux</h1>
|
<h1>CreamLinux</h1>
|
||||||
|
|
||||||
<div className="loading-animation">
|
<div className="loading-animation">
|
||||||
<div className="loading-circles">
|
<div className="loading-circles">
|
||||||
<div className="circle circle-1"></div>
|
<div className="circle circle-1"></div>
|
||||||
@@ -17,14 +37,17 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, pr
|
|||||||
<div className="circle circle-3"></div>
|
<div className="circle circle-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="loading-message">{message}</p>
|
<p className="loading-message">{message}</p>
|
||||||
|
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InitialLoadingScreen
|
export default InitialLoadingScreen
|
||||||
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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AppProvider } from '@/contexts/index.ts'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AppProvider>
|
||||||
|
<App />
|
||||||
|
</AppProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
* Game image sources from Steam's CDN
|
* Game image sources from Steam's CDN
|
||||||
*/
|
*/
|
||||||
export const SteamImageType = {
|
export const SteamImageType = {
|
||||||
HEADER: 'header', // 460x215
|
HEADER: 'header', // 460x215
|
||||||
CAPSULE: 'capsule_616x353', // 616x353
|
CAPSULE: 'capsule_616x353', // 616x353
|
||||||
LOGO: 'logo', // Game logo with transparency
|
LOGO: 'logo', // Game logo with transparency
|
||||||
LIBRARY_HERO: 'library_hero', // 1920x620
|
LIBRARY_HERO: 'library_hero', // 1920x620
|
||||||
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type SteamImageTypeKey = keyof typeof SteamImageType
|
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()
|
const imageCache: Map<string, string> = new Map()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +23,7 @@ const imageCache: Map<string, string> = new Map()
|
|||||||
export const getSteamImageUrl = (
|
export const getSteamImageUrl = (
|
||||||
appId: string,
|
appId: string,
|
||||||
type: (typeof SteamImageType)[SteamImageTypeKey]
|
type: (typeof SteamImageType)[SteamImageTypeKey]
|
||||||
) => {
|
): string => {
|
||||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
|
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
|
// 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) {
|
for (const type of typesToTry) {
|
||||||
const url = getSteamImageUrl(appId, type)
|
const url = getSteamImageUrl(appId, type)
|
||||||
const exists = await checkImageExists(url)
|
const exists = await checkImageExists(url)
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
// Preload the image to prevent flickering
|
// Preload the image to prevent flickering
|
||||||
@@ -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
|
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-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src:
|
src: url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||||
url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
|
||||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use './variables' as *;
|
/*
|
||||||
@use './mixins' as *;
|
Layout styles
|
||||||
|
Main layout structure for the application
|
||||||
|
*/
|
||||||
.app-container {
|
.app-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -21,67 +22,18 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image:
|
background-image: radial-gradient(
|
||||||
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
|
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%);
|
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: var(--z-bg);
|
z-index: var(--z-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Main content area
|
||||||
.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 {
|
.main-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -91,171 +43,109 @@
|
|||||||
z-index: var(--z-elevate);
|
z-index: var(--z-elevate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar
|
// Error message container
|
||||||
.sidebar {
|
.error-container {
|
||||||
width: var(--sidebar-width);
|
display: flex;
|
||||||
min-width: var(--sidebar-width);
|
flex-direction: column;
|
||||||
background-color: var(--secondary-bg);
|
align-items: center;
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
justify-content: center;
|
||||||
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
|
padding: 2rem;
|
||||||
padding: 1.5rem 1rem;
|
margin: 2rem auto;
|
||||||
@include flex-column;
|
max-width: 600px;
|
||||||
height: 100%;
|
border-radius: var(--radius-lg);
|
||||||
overflow-y: auto;
|
background-color: rgba(var(--danger), 0.05);
|
||||||
z-index: var(--z-elevate) + 1;
|
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 {
|
h2 {
|
||||||
color: var(--text-primary);
|
font-size: 1.5rem;
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include custom-scrollbar;
|
details {
|
||||||
}
|
margin: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
// Game list container
|
summary {
|
||||||
.game-list {
|
cursor: pointer;
|
||||||
padding: 1.5rem;
|
color: var(--text-secondary);
|
||||||
flex: 1;
|
margin-bottom: 0.5rem;
|
||||||
overflow-y: auto;
|
}
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
@include custom-scrollbar;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
h2 {
|
p {
|
||||||
font-size: 1.4rem;
|
padding: 1rem;
|
||||||
font-weight: 700;
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
margin-bottom: 1.5rem;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
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;
|
letter-spacing: 0.5px;
|
||||||
position: relative;
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
display: inline-block;
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
|
|
||||||
&:after {
|
&:hover {
|
||||||
content: '';
|
transform: translateY(-2px);
|
||||||
position: absolute;
|
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, var(--primary-color), transparent);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game grid
|
// Error message styling in the game list
|
||||||
.game-grid {
|
.error-message {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
flex-direction: column;
|
||||||
gap: 2rem;
|
align-items: center;
|
||||||
width: 100%;
|
justify-content: center;
|
||||||
padding: 0.5rem 0.5rem 2rem 0.5rem;
|
padding: 2rem;
|
||||||
scroll-behavior: smooth;
|
margin: 2rem auto;
|
||||||
align-items: stretch;
|
max-width: 600px;
|
||||||
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;
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background-color: rgba(255, 255, 255, 0.03);
|
background-color: rgba(var(--danger), 0.05);
|
||||||
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
|
border: 1px solid rgb(var(--danger), 0.2);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
.loading-indicator {
|
h3 {
|
||||||
position: relative;
|
color: var(--danger);
|
||||||
overflow: hidden;
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
&:after {
|
p {
|
||||||
content: '';
|
margin-bottom: 1.5rem;
|
||||||
position: absolute;
|
color: var(--text-secondary);
|
||||||
top: 0;
|
white-space: pre-wrap;
|
||||||
left: -100%;
|
word-break: break-word;
|
||||||
width: 50%;
|
}
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
button {
|
||||||
animation: loading-shimmer 2s infinite;
|
background-color: var(--primary-color);
|
||||||
}
|
color: var(--text-primary);
|
||||||
}
|
border: none;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
// Responsive adjustments
|
border-radius: var(--radius-sm);
|
||||||
@include media-sm {
|
font-weight: 600;
|
||||||
.game-grid {
|
letter-spacing: 0.5px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
}
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
}
|
|
||||||
|
&:hover {
|
||||||
@include media-lg {
|
transform: translateY(-2px);
|
||||||
.game-grid {
|
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@use './variables' as *;
|
/*
|
||||||
|
Mixins for reusable style patterns
|
||||||
|
*/
|
||||||
|
|
||||||
// Basic flex helpers
|
// Basic flex helpers
|
||||||
@mixin flex-center {
|
@mixin flex-center {
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
@mixin card {
|
@mixin card {
|
||||||
background-color: var(--secondary-bg);
|
background-color: var(--secondary-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@include shadow;
|
@include shadow-standard;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@use './variables' as *;
|
/*
|
||||||
@use './mixins' as *;
|
CSS Reset and base styles
|
||||||
@use './fonts' as *;
|
*/
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,54 +1,8 @@
|
|||||||
@use './fonts' as *;
|
/*
|
||||||
|
Variables for consistent styling
|
||||||
|
*/
|
||||||
|
|
||||||
// Color palette
|
|
||||||
:root {
|
: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
|
// Animation durations
|
||||||
--duration-fast: 100ms;
|
--duration-fast: 100ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 200ms;
|
||||||
@@ -99,6 +53,7 @@
|
|||||||
--z-tooltip: 1500;
|
--z-tooltip: 1500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Color variables for SCSS usage
|
||||||
$success-color: #55e07a;
|
$success-color: #55e07a;
|
||||||
$danger-color: #ff5252;
|
$danger-color: #ff5252;
|
||||||
$primary-color: #4a76c4;
|
$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 {
|
.animated-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.game-item-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: var(--card-height);
|
height: var(--card-height);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
@include shadow-standard;
|
box-shadow: var(--shadow-standard);
|
||||||
@include transition-standard;
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
|
|
||||||
// Simple image loading animation
|
// Simple image loading animation
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
// Hover effects for the card
|
// Hover effects for the card
|
||||||
.game-item-card:hover {
|
.game-item-card:hover {
|
||||||
transform: translateY(-8px) scale(1.02);
|
transform: translateY(-8px) scale(1.02);
|
||||||
@include shadow-hover;
|
box-shadow: var(--shadow-hover);
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
|
||||||
.status-badge.native {
|
.status-badge.native {
|
||||||
@@ -41,18 +42,14 @@
|
|||||||
|
|
||||||
// Special styling for cards with different statuses
|
// Special styling for cards with different statuses
|
||||||
.game-item-card:has(.status-badge.cream) {
|
.game-item-card:has(.status-badge.cream) {
|
||||||
box-shadow:
|
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
|
||||||
var(--shadow-standard),
|
|
||||||
0 0 15px rgba(128, 181, 255, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-item-card:has(.status-badge.smoke) {
|
.game-item-card:has(.status-badge.smoke) {
|
||||||
box-shadow:
|
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
|
||||||
var(--shadow-standard),
|
|
||||||
0 0 15px rgba(255, 239, 150, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple clean overlay
|
// Game item overlay
|
||||||
.game-item-overlay {
|
.game-item-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -65,7 +62,8 @@
|
|||||||
rgba(0, 0, 0, 0.6) 50%,
|
rgba(0, 0, 0, 0.6) 50%,
|
||||||
rgba(0, 0, 0, 0.8) 100%
|
rgba(0, 0, 0, 0.8) 100%
|
||||||
);
|
);
|
||||||
@include flex-column;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -77,6 +75,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game badges
|
||||||
.game-badges {
|
.game-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -97,7 +96,7 @@
|
|||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
color: var(--text-heavy);
|
color: var(--text-heavy);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
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);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
color: var(--text-heavy);
|
color: var(--text-heavy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game title
|
||||||
.game-title {
|
.game-title {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -140,6 +140,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game actions
|
||||||
.game-actions {
|
.game-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -147,91 +148,7 @@
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
// API not found message
|
||||||
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -288,9 +205,3 @@
|
|||||||
opacity: 1;
|
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 *;
|
Animated background styles
|
||||||
@use 'sass:color';
|
*/
|
||||||
|
|
||||||
.animated-background {
|
.animated-background {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
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 {
|
.initial-loading-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
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 './variables' as *;
|
||||||
@use './mixins' as *;
|
@use './mixins' as *;
|
||||||
@use './fonts' as *;
|
@use './fonts' as *;
|
||||||
|
|
||||||
// Reset
|
/* Reset & global styles */
|
||||||
@use './reset';
|
@use './reset';
|
||||||
|
|
||||||
// Layout
|
|
||||||
@use './layout';
|
@use './layout';
|
||||||
|
|
||||||
// Components
|
/* Layout components */
|
||||||
@use './components/gamecard';
|
@use 'components/layout/header';
|
||||||
@use './components/dialog';
|
@use 'components/layout/sidebar';
|
||||||
@use './components/background';
|
@use 'components/layout/background';
|
||||||
@use './components/sidebar';
|
@use 'components/layout/loading_screen';
|
||||||
@use './components/dlc_dialog';
|
|
||||||
@use './components/loading_screen';
|
/* Game components */
|
||||||
@use './components/animated_checkbox';
|
@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": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["*"]
|
||||||
|
},
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user