formatting

This commit is contained in:
Tickbase
2025-05-17 22:49:09 +02:00
parent ecd05f1980
commit 76bfea819b
46 changed files with 2905 additions and 2743 deletions

View File

@@ -7,37 +7,46 @@ assignees: ''
--- ---
## Bug Description ## Bug Description
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
## Steps To Reproduce ## Steps To Reproduce
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
## Expected Behavior ## Expected Behavior
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
## Screenshots ## Screenshots
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
## System Information ## System Information
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
- Desktop Environment: [e.g. GNOME, KDE, etc.] - OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
- CreamLinux Version: [e.g. 0.1.0] - Desktop Environment: [e.g. GNOME, KDE, etc.]
- Steam Version: [e.g. latest] - CreamLinux Version: [e.g. 0.1.0]
- Steam Version: [e.g. latest]
## Game Information ## Game Information
- Game name: - Game name:
- Game ID (if known): - Game ID (if known):
- Native Linux or Proton: - Native Linux or Proton:
- Steam installation path: - Steam installation path:
## Additional Context ## Additional Context
Add any other context about the problem here. Add any other context about the problem here.
## Logs ## Logs
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file. If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
``` ```
Paste log content here Paste log content here
``` ```

View File

@@ -7,17 +7,22 @@ assignees: ''
--- ---
## Feature Description ## Feature Description
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
## Problem This Feature Solves ## Problem This Feature Solves
Is your feature request related to a problem? Please describe. Is your feature request related to a problem? Please describe.
Ex. I'm always frustrated when [...] Ex. I'm always frustrated when [...]
## Alternatives You've Considered ## Alternatives You've Considered
A clear and concise description of any alternative solutions or features you've considered. A clear and concise description of any alternative solutions or features you've considered.
## Additional Context ## Additional Context
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
## Implementation Ideas (Optional) ## Implementation Ideas (Optional)
If you have any ideas on how this feature could be implemented, please share them here. If you have any ideas on how this feature could be implemented, please share them here.

View File

@@ -1,10 +1,10 @@
name: "Build CreamLinux" name: 'Build CreamLinux'
on: on:
push: push:
branches: [ "main" ] branches: ['main']
pull_request: pull_request:
branches: [ "main" ] branches: ['main']
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -19,32 +19,32 @@ jobs:
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 19 node-version: 19
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: stable toolchain: stable
profile: minimal profile: minimal
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies - name: Install frontend dependencies
run: npm install run: npm install
- name: Run ESLint - name: Run ESLint
run: npm run lint run: npm run lint
- name: Build the app - name: Build the app
run: npm run tauri build run: npm run tauri build
- name: Upload binary artifacts - name: Upload binary artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
dist
node_modules
src-tauri/target

View File

@@ -1,6 +1,6 @@
{ {
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "es5" "trailingComma": "es5"
} }

View File

@@ -29,23 +29,28 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
### Building from Source ### Building from Source
#### Prerequisites #### Prerequisites
- Rust 1.77.2 or later - Rust 1.77.2 or later
- Node.js 18 or later - Node.js 18 or later
- npm or yarn - npm or yarn
#### Steps #### Steps
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/yourusername/creamlinux.git git clone https://github.com/yourusername/creamlinux.git
cd creamlinux cd creamlinux
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
npm install # or yarn npm install # or yarn
``` ```
3. Build the application: 3. Build the application:
```bash ```bash
NO_STRIP=true npm run tauri build NO_STRIP=true npm run tauri build
``` ```
@@ -57,11 +62,13 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
If you're using the AppImage version, you can integrate it into your desktop environment: If you're using the AppImage version, you can integrate it into your desktop environment:
1. Create a desktop entry file: 1. Create a desktop entry file:
```bash ```bash
mkdir -p ~/.local/share/applications mkdir -p ~/.local/share/applications
``` ```
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage): 2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
``` ```
[Desktop Entry] [Desktop Entry]
Name=Creamlinux Name=Creamlinux
@@ -73,6 +80,7 @@ If you're using the AppImage version, you can integrate it into your desktop env
``` ```
3. Update your desktop database so creamlinux appears in your app launcher: 3. Update your desktop database so creamlinux appears in your app launcher:
```bash ```bash
update-desktop-database ~/.local/share/applications update-desktop-database ~/.local/share/applications
``` ```
@@ -114,4 +122,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support - [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support - [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
- [Tauri](https://tauri.app/) - Framework for building the desktop application - [Tauri](https://tauri.app/) - Framework for building the desktop application
- [React](https://reactjs.org/) - UI library - [React](https://reactjs.org/) - UI library

View File

@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist', 'node_modules', 'src-tauri/target'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
@@ -19,10 +19,7 @@ export default tseslint.config(
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'warn',
{ allowConstantExport: true },
],
}, },
}, }
) )

View File

@@ -8,9 +8,6 @@ repository = ""
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.2.0", features = [] } tauri-build = { version = "2.2.0", features = [] }
@@ -37,6 +34,4 @@ num_cpus = "1.16.0"
futures = "0.3.31" futures = "0.3.31"
[features] [features]
# this feature is used for production builds or when `devPath` points to the filesystem custom-protocol = ["tauri/custom-protocol"]
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

View File

@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }

View File

@@ -2,10 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "enables the default permissions", "description": "enables the default permissions",
"windows": [ "windows": ["main"],
"main" "permissions": ["core:default"]
],
"permissions": [
"core:default"
]
} }

View File

@@ -1,13 +1,11 @@
// src/cache.rs use crate::dlc_manager::DlcInfoWithState;
use log::{info, warn};
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::path::{PathBuf};
use std::fs; use std::fs;
use std::io; use std::io;
use std::time::{SystemTime}; use std::path::PathBuf;
use log::{info, warn}; use std::time::SystemTime;
use crate::dlc_manager::DlcInfoWithState;
// Cache entry with timestamp for expiration // Cache entry with timestamp for expiration
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -20,14 +18,14 @@ struct CacheEntry<T> {
fn get_cache_dir() -> io::Result<PathBuf> { fn get_cache_dir() -> io::Result<PathBuf> {
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux") let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let cache_dir = xdg_dirs.get_cache_home(); let cache_dir = xdg_dirs.get_cache_home();
// Make sure the cache directory exists // Make sure the cache directory exists
if !cache_dir.exists() { if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)?; fs::create_dir_all(&cache_dir)?;
} }
Ok(cache_dir) Ok(cache_dir)
} }
@@ -38,26 +36,26 @@ where
{ {
let cache_dir = get_cache_dir()?; let cache_dir = get_cache_dir()?;
let cache_file = cache_dir.join(format!("{}.cache", key)); let cache_file = cache_dir.join(format!("{}.cache", key));
// Get current timestamp // Get current timestamp
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
// Create a JSON object with timestamp and data directly // Create a JSON object with timestamp and data directly
let json_data = json!({ let json_data = json!({
"timestamp": now, "timestamp": now,
"data": data // No clone needed here "data": data // No clone needed here
}); });
// Serialize and write to file // Serialize and write to file
let serialized = serde_json::to_string(&json_data) let serialized =
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(cache_file, serialized)?; fs::write(cache_file, serialized)?;
info!("Saved cache for key: {}", key); info!("Saved cache for key: {}", key);
Ok(()) Ok(())
} }
@@ -73,14 +71,14 @@ where
return None; return None;
} }
}; };
let cache_file = cache_dir.join(format!("{}.cache", key)); let cache_file = cache_dir.join(format!("{}.cache", key));
// Check if cache file exists // Check if cache file exists
if !cache_file.exists() { if !cache_file.exists() {
return None; return None;
} }
// Read and deserialize // Read and deserialize
let cached_data = match fs::read_to_string(&cache_file) { let cached_data = match fs::read_to_string(&cache_file) {
Ok(data) => data, Ok(data) => data,
@@ -89,54 +87,58 @@ where
return None; return None;
} }
}; };
// Parse the JSON // Parse the JSON
let json_value: serde_json::Value = match serde_json::from_str(&cached_data) { let json_value: serde_json::Value = match serde_json::from_str(&cached_data) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
warn!("Failed to parse cache file {}: {}", cache_file.display(), e); warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
return None; return None;
} }
}; };
// Extract timestamp // Extract timestamp
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) { let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
Some(ts) => ts, Some(ts) => ts,
None => { None => {
warn!("Invalid timestamp in cache file {}", cache_file.display()); warn!("Invalid timestamp in cache file {}", cache_file.display());
return None; return None;
} }
}; };
// Check expiration // Check expiration
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let age_hours = (now - timestamp) / 3600; let age_hours = (now - timestamp) / 3600;
if age_hours > ttl_hours { if age_hours > ttl_hours {
info!("Cache for key {} is expired ({} hours old)", key, age_hours); info!("Cache for key {} is expired ({} hours old)", key, age_hours);
return None; return None;
} }
// Extract data // Extract data
let data: T = match serde_json::from_value(json_value["data"].clone()) { let data: T = match serde_json::from_value(json_value["data"].clone()) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e); warn!(
return None; "Failed to parse data in cache file {}: {}",
} cache_file.display(),
}; e
);
info!("Using cache for key {} ({} hours old)", key, age_hours); return None;
Some(data) }
};
info!("Using cache for key {} ({} hours old)", key, age_hours);
Some(data)
} }
// Cache game scanning results // Cache game scanning results
pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> { pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> {
save_to_cache("games", games, 24) // Cache games for 24 hours save_to_cache("games", games, 24) // Cache games for 24 hours
} }
// Load cached game scanning results // Load cached game scanning results
@@ -146,7 +148,7 @@ pub fn load_cached_games() -> Option<Vec<crate::installer::Game>> {
// Cache DLC list for a game // Cache DLC list for a game
pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> { 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) save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
} }
// Load cached DLC list // Load cached DLC list
@@ -157,11 +159,11 @@ pub fn load_cached_dlcs(game_id: &str) -> Option<Vec<DlcInfoWithState>> {
// Clear all caches // Clear all caches
pub fn clear_all_caches() -> io::Result<()> { pub fn clear_all_caches() -> io::Result<()> {
let cache_dir = get_cache_dir()?; let cache_dir = get_cache_dir()?;
for entry in fs::read_dir(cache_dir)? { for entry in fs::read_dir(cache_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") { if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") {
if let Err(e) = fs::remove_file(&path) { if let Err(e) = fs::remove_file(&path) {
warn!("Failed to remove cache file {}: {}", path.display(), e); warn!("Failed to remove cache file {}: {}", path.display(), e);
@@ -170,7 +172,7 @@ pub fn clear_all_caches() -> io::Result<()> {
} }
} }
} }
info!("All caches cleared"); info!("All caches cleared");
Ok(()) Ok(())
} }

View File

@@ -1,12 +1,11 @@
// src/dlc_manager.rs use log::{error, info};
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use log::{info, error};
use std::collections::{HashMap, HashSet};
use tauri::Manager; use tauri::Manager;
/// More detailed DLC information with enabled state // More detailed DLC information with enabled state
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfoWithState { pub struct DlcInfoWithState {
pub appid: String, pub appid: String,
@@ -14,39 +13,42 @@ pub struct DlcInfoWithState {
pub enabled: bool, pub enabled: bool,
} }
/// Parse the cream_api.ini file to extract both enabled and disabled DLCs // Parse the cream_api.ini file to extract both enabled and disabled DLCs
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> { pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
info!("Reading enabled DLCs from {}", game_path); info!("Reading enabled DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
} }
let contents = match fs::read_to_string(&cream_api_path) { let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
}; };
// Extract DLCs - they are in the [dlc] section with format "appid = name" // Extract DLCs
let mut in_dlc_section = false; let mut in_dlc_section = false;
let mut enabled_dlcs = Vec::new(); let mut enabled_dlcs = Vec::new();
for line in contents.lines() { for line in contents.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
// Check if we're in the DLC section // Check if we're in the DLC section
if trimmed == "[dlc]" { if trimmed == "[dlc]" {
in_dlc_section = true; in_dlc_section = true;
continue; continue;
} }
// Check if we're leaving the DLC section (another section begins) // Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
continue; continue;
} }
// Skip empty lines and non-DLC comments // Skip empty lines and non-DLC comments
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') { if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
// Extract the DLC app ID // Extract the DLC app ID
@@ -59,44 +61,47 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
} }
} }
} }
info!("Found {} enabled DLCs", enabled_dlcs.len()); info!("Found {} enabled DLCs", enabled_dlcs.len());
Ok(enabled_dlcs) Ok(enabled_dlcs)
} }
/// Get all DLCs (both enabled and disabled) from cream_api.ini // Get all DLCs (both enabled and disabled) from cream_api.ini
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> { pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
info!("Reading all DLCs from {}", game_path); info!("Reading all DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
} }
let contents = match fs::read_to_string(&cream_api_path) { let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
}; };
// Extract DLCs - both enabled and disabled // Extract DLCs
let mut in_dlc_section = false; let mut in_dlc_section = false;
let mut all_dlcs = Vec::new(); let mut all_dlcs = Vec::new();
for line in contents.lines() { for line in contents.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
// Check if we're in the DLC section // Check if we're in the DLC section
if trimmed == "[dlc]" { if trimmed == "[dlc]" {
in_dlc_section = true; in_dlc_section = true;
continue; continue;
} }
// Check if we're leaving the DLC section (another section begins) // Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
continue; continue;
} }
// Process DLC entries (both enabled and commented/disabled) // Process DLC entries (both enabled and commented/disabled)
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') { if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
let is_commented = trimmed.starts_with("#"); let is_commented = trimmed.starts_with("#");
@@ -105,12 +110,12 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
} else { } else {
trimmed trimmed
}; };
let parts: Vec<&str> = actual_line.splitn(2, '=').collect(); let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 { if parts.len() == 2 {
let appid = parts[0].trim(); let appid = parts[0].trim();
let name = parts[1].trim(); let name = parts[1].trim();
all_dlcs.push(DlcInfoWithState { all_dlcs.push(DlcInfoWithState {
appid: appid.to_string(), appid: appid.to_string(),
name: name.to_string().trim_matches('"').to_string(), name: name.to_string().trim_matches('"').to_string(),
@@ -119,56 +124,65 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
} }
} }
} }
info!("Found {} total DLCs ({} enabled, {} disabled)", info!(
all_dlcs.len(), "Found {} total DLCs ({} enabled, {} disabled)",
all_dlcs.iter().filter(|d| d.enabled).count(), all_dlcs.len(),
all_dlcs.iter().filter(|d| !d.enabled).count()); all_dlcs.iter().filter(|d| d.enabled).count(),
all_dlcs.iter().filter(|d| !d.enabled).count()
);
Ok(all_dlcs) Ok(all_dlcs)
} }
/// Update the cream_api.ini file with the user's DLC selections // Update the cream_api.ini file with the user's DLC selections
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> { pub fn update_dlc_configuration(
game_path: &str,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for {}", game_path); info!("Updating DLC configuration for {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
} }
// Read the current file contents // Read the current file contents
let current_contents = match fs::read_to_string(&cream_api_path) { let current_contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
}; };
// Create a mapping of DLC appid to its state for easy lookup // Create a mapping of DLC appid to its state for easy lookup
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter() let dlc_states: HashMap<String, (bool, String)> = dlcs
.iter()
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone()))) .map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
.collect(); .collect();
// Keep track of processed DLCs to avoid duplicates // Keep track of processed DLCs to avoid duplicates
let mut processed_dlcs = HashSet::new(); let mut processed_dlcs = HashSet::new();
// Process the file line by line to retain most of the original structure // Process the file line by line to retain most of the original structure
let mut new_contents = Vec::new(); let mut new_contents = Vec::new();
let mut in_dlc_section = false; let mut in_dlc_section = false;
for line in current_contents.lines() { for line in current_contents.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
// Add section markers directly // Add section markers directly
if trimmed == "[dlc]" { if trimmed == "[dlc]" {
in_dlc_section = true; in_dlc_section = true;
new_contents.push(line.to_string()); new_contents.push(line.to_string());
continue; continue;
} }
// Check if we're leaving the DLC section (another section begins) // Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
// Before leaving the DLC section, add any DLCs that weren't processed yet // Before leaving the DLC section, add any DLCs that weren't processed yet
for (appid, (enabled, name)) in &dlc_states { for (appid, (enabled, name)) in &dlc_states {
if !processed_dlcs.contains(appid) { if !processed_dlcs.contains(appid) {
@@ -179,21 +193,21 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} }
} }
} }
// Now add the section marker // Now add the section marker
new_contents.push(line.to_string()); new_contents.push(line.to_string());
continue; continue;
} }
if in_dlc_section && !trimmed.is_empty() { if in_dlc_section && !trimmed.is_empty() {
let is_comment_line = trimmed.starts_with(';'); let is_comment_line = trimmed.starts_with(';');
// If it's a regular comment line (not a DLC), keep it as is // If it's a regular comment line (not a DLC), keep it as is
if is_comment_line { if is_comment_line {
new_contents.push(line.to_string()); new_contents.push(line.to_string());
continue; continue;
} }
// Check if it's a commented-out DLC line or a regular DLC line // Check if it's a commented-out DLC line or a regular DLC line
let is_commented = trimmed.starts_with("#"); let is_commented = trimmed.starts_with("#");
let actual_line = if is_commented { let actual_line = if is_commented {
@@ -201,13 +215,13 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} else { } else {
trimmed trimmed
}; };
// Extract appid and name // Extract appid and name
let parts: Vec<&str> = actual_line.splitn(2, '=').collect(); let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 { if parts.len() == 2 {
let appid = parts[0].trim(); let appid = parts[0].trim();
let name = parts[1].trim(); let name = parts[1].trim();
// Check if this DLC exists in our updated list // Check if this DLC exists in our updated list
if let Some((enabled, _)) = dlc_states.get(appid) { if let Some((enabled, _)) = dlc_states.get(appid) {
// Add the DLC with its updated state // Add the DLC with its updated state
@@ -218,19 +232,19 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} }
processed_dlcs.insert(appid.to_string()); processed_dlcs.insert(appid.to_string());
} else { } else {
// Not in our list - keep the original line // Not in our list keep the original line
new_contents.push(line.to_string()); new_contents.push(line.to_string());
} }
} else { } else {
// Invalid format or not a DLC line - keep as is // Invalid format or not a DLC line keep as is
new_contents.push(line.to_string()); new_contents.push(line.to_string());
} }
} else if !in_dlc_section || trimmed.is_empty() { } else if !in_dlc_section || trimmed.is_empty() {
// Not a DLC line or empty line - keep as is // Not a DLC line or empty line keep as is
new_contents.push(line.to_string()); new_contents.push(line.to_string());
} }
} }
// If we never left the DLC section, make sure we add any unprocessed DLCs // If we never left the DLC section, make sure we add any unprocessed DLCs
if in_dlc_section { if in_dlc_section {
for (appid, (enabled, name)) in &dlc_states { for (appid, (enabled, name)) in &dlc_states {
@@ -243,13 +257,16 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} }
} }
} }
// Write the updated file // Write the updated file
match fs::write(&cream_api_path, new_contents.join("\n")) { match fs::write(&cream_api_path, new_contents.join("\n")) {
Ok(_) => { Ok(_) => {
info!("Successfully updated DLC configuration at {}", cream_api_path.display()); info!(
"Successfully updated DLC configuration at {}",
cream_api_path.display()
);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to write updated cream_api.ini: {}", e); error!("Failed to write updated cream_api.ini: {}", e);
Err(format!("Failed to write updated cream_api.ini: {}", e)) Err(format!("Failed to write updated cream_api.ini: {}", e))
@@ -257,7 +274,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} }
} }
/// Get app ID from game path by reading cream_api.ini // Get app ID from game path by reading cream_api.ini
#[allow(dead_code)] #[allow(dead_code)]
fn extract_app_id_from_config(game_path: &str) -> Option<String> { fn extract_app_id_from_config(game_path: &str) -> Option<String> {
if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) { if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) {
@@ -269,71 +286,83 @@ fn extract_app_id_from_config(game_path: &str) -> Option<String> {
None None
} }
/// Create a custom installation with selected DLCs // Create a custom installation with selected DLCs
pub async fn install_cream_with_dlcs( pub async fn install_cream_with_dlcs(
game_id: String, game_id: String,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
selected_dlcs: Vec<DlcInfoWithState> selected_dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::AppState; use crate::AppState;
// Count enabled DLCs for logging // Count enabled DLCs for logging
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count(); let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count); info!(
"Starting installation of CreamLinux with {} selected DLCs",
// Get the game from state enabled_dlc_count
let game = { );
let state = app_handle.state::<AppState>();
let games = state.games.lock(); // Get the game from state
match games.get(&game_id) { let game = {
Some(g) => g.clone(), let state = app_handle.state::<AppState>();
None => return Err(format!("Game with ID {} not found", game_id)) let games = state.games.lock();
} match games.get(&game_id) {
}; Some(g) => g.clone(),
None => return Err(format!("Game with ID {} not found", game_id)),
info!("Installing CreamLinux for game: {} ({})", game.title, game_id); }
};
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
use crate::installer::install_creamlinux_with_dlcs; info!(
"Installing CreamLinux for game: {} ({})",
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled game.title, game_id
let enabled_dlcs = selected_dlcs.iter() );
.filter(|dlc| dlc.enabled)
.map(|dlc| crate::installer::DlcInfo { // Install CreamLinux first - but provide the DLCs directly instead of fetching them again
appid: dlc.appid.clone(), use crate::installer::install_creamlinux_with_dlcs;
name: dlc.name.clone(),
}) // Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
.collect::<Vec<_>>(); let enabled_dlcs = selected_dlcs
.iter()
let app_handle_clone = app_handle.clone(); .filter(|dlc| dlc.enabled)
let game_title = game.title.clone(); .map(|dlc| crate::installer::DlcInfo {
appid: dlc.appid.clone(),
// Use direct installation with provided DLCs instead of re-fetching name: dlc.name.clone(),
match install_creamlinux_with_dlcs( })
&game.path, .collect::<Vec<_>>();
&game_id,
enabled_dlcs, let app_handle_clone = app_handle.clone();
move |progress, message| { let game_title = game.title.clone();
// Emit progress updates during installation
use crate::installer::emit_progress; // Use direct installation with provided DLCs instead of re-fetching
emit_progress( match install_creamlinux_with_dlcs(
&app_handle_clone, &game.path,
&format!("Installing CreamLinux for {}", game_title), &game_id,
message, enabled_dlcs,
progress * 100.0, // Scale progress from 0 to 100% move |progress, message| {
false, // Emit progress updates during installation
false, use crate::installer::emit_progress;
None emit_progress(
); &app_handle_clone,
} &format!("Installing CreamLinux for {}", game_title),
).await { message,
Ok(_) => { progress * 100.0, // Scale progress from 0 to 100%
info!("CreamLinux installation completed successfully for game: {}", game.title); false,
Ok(()) false,
}, None,
Err(e) => { );
error!("Failed to install CreamLinux: {}", e); },
Err(format!("Failed to install CreamLinux: {}", e)) )
} .await
} {
} Ok(_) => {
info!(
"CreamLinux installation completed successfully for game: {}",
game.title
);
Ok(())
}
Err(e) => {
error!("Failed to install CreamLinux: {}", e);
Err(format!("Failed to install CreamLinux: {}", e))
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,130 @@
// src/main.rs
#![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 searcher; mod cache;
mod installer;
mod dlc_manager; mod dlc_manager;
mod cache; // Keep the module for now, but we won't use its functionality mod installer;
mod searcher; // Keep the module for now
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use parking_lot::Mutex;
use tokio::time::Instant;
use tokio::time::Duration;
use tauri::State;
use tauri::{Manager, Emitter};
use log::{info, warn, error, debug};
use installer::{InstallerType, InstallerAction, Game};
use dlc_manager::DlcInfoWithState; use dlc_manager::DlcInfoWithState;
use std::sync::Arc; use installer::{Game, InstallerAction, InstallerType};
use log::{debug, error, info, warn};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc;
use tauri::State;
use tauri::{Emitter, Manager};
use tokio::time::Duration;
use tokio::time::Instant;
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameAction { pub struct GameAction {
game_id: String, game_id: String,
action: String, action: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DlcCache { struct DlcCache {
data: Vec<DlcInfoWithState>, data: Vec<DlcInfoWithState>,
timestamp: Instant, 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(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> { async fn scan_steam_games(
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> 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!("Found {} Steam library directories:", unique_libraries.len()); info!(
"Found {} Steam library directories:",
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(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20); emit_scan_progress(
&app_handle,
&format!(
"Found {} Steam libraries. Starting game scan...",
unique_libraries.len()
),
20,
);
// Find installed games // Find installed games
let games_info = searcher::find_installed_games(&libraries).await; let games_info = searcher::find_installed_games(&libraries).await;
emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90); emit_scan_progress(
&app_handle,
&format!("Found {} games. Processing...", games_info.len()),
90,
);
// Log summary of games found // Log summary of games found
info!("Games scan complete - Found {} games", games_info.len()); info!("Games scan complete - Found {} games", games_info.len());
info!("Native games: {}", games_info.iter().filter(|g| g.native).count()); info!(
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count()); "Native games: {}",
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count()); games_info.iter().filter(|g| g.native).count()
info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).count()); );
info!(
"Proton games: {}",
games_info.iter().filter(|g| !g.native).count()
);
info!(
"Games with CreamLinux: {}",
games_info.iter().filter(|g| g.cream_installed).count()
);
info!(
"Games with SmokeAPI: {}",
games_info.iter().filter(|g| g.smoke_installed).count()
);
// Convert to our Game struct // 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!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", debug!(
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed); "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
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,
@@ -105,383 +135,413 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
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(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100); emit_scan_progress(
&app_handle,
&format!("Scan complete. Found {} games.", result.len()),
100,
);
info!("Game scan completed successfully"); 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.get(&game_id) games
.cloned() .get(&game_id)
.ok_or_else(|| format!("Game with ID {} not found", game_id)) .cloned()
.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.get(&game_action.game_id) games
.cloned() .get(&game_action.game_id)
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? .cloned()
}; .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
// Parse the action string to determine type and operation // 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) let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
.ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?; format!(
"Game with ID {} not found after action",
// Update installation status game_action.game_id
match (installer_type, action) { )
(InstallerType::Cream, InstallerAction::Install) => { })?;
game.cream_installed = true;
}, // Update installation status
(InstallerType::Cream, InstallerAction::Uninstall) => { match (installer_type, action) {
game.cream_installed = false; (InstallerType::Cream, InstallerAction::Install) => {
}, game.cream_installed = true;
(InstallerType::Smoke, InstallerAction::Install) => { }
game.smoke_installed = true; (InstallerType::Cream, InstallerAction::Uninstall) => {
}, game.cream_installed = false;
(InstallerType::Smoke, InstallerAction::Uninstall) => { }
game.smoke_installed = false; (InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true;
}
(InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false;
}
} }
// Reset installing flag
game.installing = false;
// Return updated game info
game.clone()
};
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
} }
// Reset installing flag
game.installing = false;
// Return updated game info
game.clone()
};
// Removed cache update Ok(updated_game)
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
Ok(updated_game)
} }
// Fetch DLC list for a game // Fetch DLC list for a game
#[tauri::command] #[tauri::command]
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> { async fn fetch_game_dlcs(
info!("Fetching DLCs for game ID: {}", game_id); game_id: String,
app_handle: tauri::AppHandle,
// Removed cache checking ) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLCs for game ID: {}", game_id);
// Always fetch fresh DLC data instead of using cache
match installer::fetch_dlc_details(&game_id).await { // Fetch DLC data
Ok(dlcs) => { match installer::fetch_dlc_details(&game_id).await {
// Convert to DlcInfoWithState (all enabled by default) Ok(dlcs) => {
let dlcs_with_state = dlcs.into_iter() // Convert to DlcInfoWithState
.map(|dlc| DlcInfoWithState { let dlcs_with_state = dlcs
appid: dlc.appid, .into_iter()
name: dlc.name, .map(|dlc| DlcInfoWithState {
enabled: true, appid: dlc.appid,
}) name: dlc.name,
.collect::<Vec<_>>(); enabled: true,
})
// Cache in memory for this session (but not on disk) .collect::<Vec<_>>();
let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock(); // Cache in memory for this session (but not on disk)
cache.insert(game_id.clone(), DlcCache { let state = app_handle.state::<AppState>();
data: dlcs_with_state.clone(), let mut cache = state.dlc_cache.lock();
timestamp: Instant::now(), cache.insert(
}); game_id.clone(),
DlcCache {
Ok(dlcs_with_state) data: dlcs_with_state.clone(),
}, timestamp: Instant::now(),
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)) },
} );
Ok(dlcs_with_state)
}
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
}
} }
#[tauri::command] #[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>();
state.fetch_cancellation.store(true, Ordering::SeqCst);
// Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst); state.fetch_cancellation.store(true, Ordering::SeqCst);
});
// Reset after a short delay
Ok(()) std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
Ok(())
} }
// 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);
// Removed cached DLC check - always fetch fresh data // Fetch DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
// Always fetch fresh DLC data from API Ok(dlcs) => {
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { info!(
Ok(dlcs) => { "Successfully streamed {} DLCs for game {}",
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id); dlcs.len(),
game_id
// Convert to DLCInfoWithState for in-memory caching only );
let dlcs_with_state = dlcs.into_iter()
.map(|dlc| DlcInfoWithState { // Convert to DLCInfoWithState for in-memory caching only
appid: dlc.appid, let dlcs_with_state = dlcs
name: dlc.name, .into_iter()
enabled: true, .map(|dlc| DlcInfoWithState {
}) appid: dlc.appid,
.collect::<Vec<_>>(); name: dlc.name,
enabled: true,
// Update in-memory cache without storing to disk })
let state = app_handle.state::<AppState>(); .collect::<Vec<_>>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(game_id.clone(), DlcCache { // Update in-memory cache without storing to disk
data: dlcs_with_state, let state = app_handle.state::<AppState>();
timestamp: tokio::time::Instant::now(), let mut dlc_cache = state.dlc_cache.lock();
}); dlc_cache.insert(
game_id.clone(),
Ok(()) DlcCache {
}, data: dlcs_with_state,
Err(e) => { timestamp: tokio::time::Instant::now(),
error!("Failed to stream DLC details: {}", e); },
// Emit error event );
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e) Ok(())
}); }
Err(e) => {
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { error!("Failed to stream DLC details: {}", e);
warn!("Failed to emit dlc-error event: {}", emit_err); // Emit error event
} let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
Err(format!("Failed to fetch DLC details: {}", e)) });
}
} if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
warn!("Failed to emit dlc-error event: {}", emit_err);
}
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(game_path: String, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> { fn update_dlc_configuration_command(
info!("Updating DLC configuration for: {}", game_path); game_path: String,
dlc_manager::update_dlc_configuration(&game_path, dlcs) dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path);
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!("Installing CreamLinux with selected DLCs for game: {}", game_id); info!(
"Installing CreamLinux with selected DLCs for game: {}",
// Clone selected_dlcs for later use game_id
let selected_dlcs_clone = selected_dlcs.clone(); );
// Install CreamLinux with the selected DLCs // Clone selected_dlcs for later use
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await { let selected_dlcs_clone = selected_dlcs.clone();
Ok(_) => {
// Return updated game info // Install CreamLinux with the selected DLCs
let state = app_handle.state::<AppState>(); match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
.await
// Get a mutable reference and update the game {
let game = { Ok(_) => {
let mut games_map = state.games.lock(); // Return updated game info
let game = games_map.get_mut(&game_id) let state = app_handle.state::<AppState>();
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?;
// Get a mutable reference and update the game
// Update installation status let game = {
game.cream_installed = true; let mut games_map = state.games.lock();
game.installing = false; let game = games_map.get_mut(&game_id).ok_or_else(|| {
format!("Game with ID {} not found after installation", game_id)
// Clone the game for returning later })?;
game.clone()
}; // mutable borrow ends here // Update installation status
game.cream_installed = true;
// Removed game caching game.installing = false;
// Emit an event to update the UI // Clone the game for returning later
if let Err(e) = app_handle.emit("game-updated", &game) { game.clone()
warn!("Failed to emit game-updated event: {}", e); };
}
// Emit an event to update the UI
// Show installation complete dialog with instructions if let Err(e) = app_handle.emit("game-updated", &game) {
let instructions = installer::InstallationInstructions { warn!("Failed to emit game-updated event: {}", e);
type_: "cream_install".to_string(), }
command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(), // Show installation complete dialog with instructions
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()) let instructions = installer::InstallationInstructions {
}; type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
installer::emit_progress( game_title: game.title.clone(),
&app_handle, dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
&format!("Installation Completed: {}", game.title), };
"CreamLinux has been installed successfully!",
100.0, installer::emit_progress(
true, &app_handle,
true, &format!("Installation Completed: {}", game.title),
Some(instructions) "CreamLinux has been installed successfully!",
); 100.0,
true,
Ok(game) true,
}, Some(instructions),
Err(e) => { );
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e)) Ok(game)
} }
} Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!(
"Failed to install CreamLinux with selected DLCs: {}",
e
))
}
}
} }
// Setup logging // 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 with improved log format // 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");
let app_state = AppState {
games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)),
};
tauri::Builder::default() info!("Initializing CreamLinux application");
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) let app_state = AppState {
.plugin(tauri_plugin_fs::init()) games: Mutex::new(HashMap::new()),
.manage(app_state) dlc_cache: Mutex::new(HashMap::new()),
.invoke_handler(tauri::generate_handler![ fetch_cancellation: Arc::new(AtomicBool::new(false)),
scan_steam_games, };
get_game_info,
process_game_action, tauri::Builder::default()
fetch_game_dlcs, .plugin(tauri_plugin_shell::init())
stream_game_dlcs, .plugin(tauri_plugin_dialog::init())
get_enabled_dlcs_command, .plugin(tauri_plugin_fs::init())
update_dlc_configuration_command, .manage(app_state)
install_cream_with_dlcs_command, .invoke_handler(tauri::generate_handler![
get_all_dlcs_command, scan_steam_games,
clear_caches, get_game_info,
abort_dlc_fetch, process_game_action,
]) fetch_game_dlcs,
.setup(|app| { stream_game_dlcs,
// Add a setup handler to do any initialization work get_enabled_dlcs_command,
info!("Tauri application setup"); update_dlc_configuration_command,
install_cream_with_dlcs_command,
#[cfg(debug_assertions)] get_all_dlcs_command,
{ clear_caches,
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { abort_dlc_fetch,
if let Some(window) = app.get_webview_window("main") { ])
window.open_devtools(); .setup(|app| {
} // Add a setup handler to do any initialization work
} info!("Tauri application setup");
}
Ok(()) #[cfg(debug_assertions)]
}) {
.run(tauri::generate_context!()) if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
.expect("error while running tauri application"); if let Some(window) = app.get_webview_window("main") {
} window.open_devtools();
}
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -1,15 +1,14 @@
// src/searcher.rs use log::{debug, error, info, warn};
use regex::Regex;
use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::collections::HashSet;
use log::{info, debug, warn, error};
use regex::Regex;
use walkdir::WalkDir;
use tokio::sync::mpsc;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc;
use walkdir::WalkDir;
/// Game information structure // Game information structure
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GameInfo { pub struct GameInfo {
pub id: String, pub id: String,
@@ -21,24 +20,24 @@ pub struct GameInfo {
pub smoke_installed: bool, pub smoke_installed: bool,
} }
/// Find potential Steam installation directories // Find potential Steam installation directories
pub fn get_default_steam_paths() -> Vec<PathBuf> { pub fn get_default_steam_paths() -> Vec<PathBuf> {
let mut paths = Vec::new(); let mut paths = Vec::new();
// Get user's home directory // Get user's home directory
if let Ok(home) = std::env::var("HOME") { if let Ok(home) = std::env::var("HOME") {
info!("Searching for Steam in home directory: {}", home); info!("Searching for Steam in home directory: {}", home);
// Common Steam installation locations on Linux // Common Steam installation locations on Linux
let common_paths = [ let common_paths = [
".steam/steam", // Steam symlink directory ".steam/steam", // Steam symlink directory
".steam/root", // Alternative symlink ".steam/root", // Alternative symlink
".local/share/Steam", // Flatpak Steam installation ".local/share/Steam", // Flatpak Steam installation
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path ".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path ".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
"/run/media/mmcblk0p1", // Removable Storage path "/run/media/mmcblk0p1", // Removable Storage path
]; ];
for path in &common_paths { for path in &common_paths {
let full_path = PathBuf::from(&home).join(path); let full_path = PathBuf::from(&home).join(path);
if full_path.exists() { if full_path.exists() {
@@ -47,13 +46,10 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
} }
} }
} }
// Add Steam Deck paths if they exist (these don't rely on HOME) // Add Steam Deck paths if they exist
let deck_paths = [ let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
"/home/deck/.steam/steam",
"/home/deck/.local/share/Steam",
];
for path in &deck_paths { for path in &deck_paths {
let p = PathBuf::from(path); let p = PathBuf::from(path);
if p.exists() && !paths.contains(&p) { if p.exists() && !paths.contains(&p) {
@@ -61,7 +57,7 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
paths.push(p); paths.push(p);
} }
} }
// Try to extract paths from Steam registry file // Try to extract paths from Steam registry file
if let Some(registry_paths) = read_steam_registry() { if let Some(registry_paths) = read_steam_registry() {
for path in registry_paths { for path in registry_paths {
@@ -71,39 +67,39 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
} }
} }
} }
info!("Found {} potential Steam directories", paths.len()); info!("Found {} potential Steam directories", paths.len());
paths paths
} }
/// Try to read the Steam registry file to find installation paths // Try to read the Steam registry file to find installation paths
fn read_steam_registry() -> Option<Vec<PathBuf>> { fn read_steam_registry() -> Option<Vec<PathBuf>> {
let home = match std::env::var("HOME") { let home = match std::env::var("HOME") {
Ok(h) => h, Ok(h) => h,
Err(_) => return None, Err(_) => return None,
}; };
let registry_paths = [ let registry_paths = [
format!("{}/.steam/registry.vdf", home), format!("{}/.steam/registry.vdf", home),
format!("{}/.steam/steam/registry.vdf", home), format!("{}/.steam/steam/registry.vdf", home),
format!("{}/.local/share/Steam/registry.vdf", home), format!("{}/.local/share/Steam/registry.vdf", home),
]; ];
for registry_path in registry_paths { for registry_path in registry_paths {
let path = Path::new(&registry_path); let path = Path::new(&registry_path);
if path.exists() { if path.exists() {
debug!("Found Steam registry at: {}", path.display()); debug!("Found Steam registry at: {}", path.display());
if let Ok(content) = fs::read_to_string(path) { if let Ok(content) = fs::read_to_string(path) {
let mut paths = Vec::new(); let mut paths = Vec::new();
// Extract Steam installation paths // Extract Steam installation paths
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap(); let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_steam_path.captures(&content) { if let Some(cap) = re_steam_path.captures(&content) {
let steam_path = PathBuf::from(&cap[1]); let steam_path = PathBuf::from(&cap[1]);
paths.push(steam_path); paths.push(steam_path);
} }
// Look for install path // Look for install path
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap(); let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_install_path.captures(&content) { if let Some(cap) = re_install_path.captures(&content) {
@@ -112,84 +108,84 @@ fn read_steam_registry() -> Option<Vec<PathBuf>> {
paths.push(install_path); paths.push(install_path);
} }
} }
if !paths.is_empty() { if !paths.is_empty() {
return Some(paths); return Some(paths);
} }
} }
} }
} }
None None
} }
/// Find all Steam library folders from base Steam installation paths // Find all Steam library folders from base Steam installation paths
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> { pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
let mut libraries = HashSet::new(); let mut libraries = HashSet::new();
for base_path in base_paths { for base_path in base_paths {
debug!("Looking for Steam libraries in: {}", base_path.display()); debug!("Looking for Steam libraries in: {}", base_path.display());
// Check if this path contains a steamapps directory // Check if this path contains a steamapps directory
let steamapps_path = base_path.join("steamapps"); let steamapps_path = base_path.join("steamapps");
if steamapps_path.exists() && steamapps_path.is_dir() { if steamapps_path.exists() && steamapps_path.is_dir() {
debug!("Found steamapps directory: {}", steamapps_path.display()); debug!("Found steamapps directory: {}", steamapps_path.display());
libraries.insert(steamapps_path.clone()); libraries.insert(steamapps_path.clone());
// Check for additional libraries in libraryfolders.vdf // Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(&steamapps_path, &mut libraries); parse_library_folders_vdf(&steamapps_path, &mut libraries);
} }
// Also check for steamapps in common locations relative to this path // Also check for steamapps in common locations relative to this path
let possible_steamapps = [ let possible_steamapps = [
base_path.join("steam/steamapps"), base_path.join("steam/steamapps"),
base_path.join("Steam/steamapps"), base_path.join("Steam/steamapps"),
]; ];
for path in &possible_steamapps { for path in &possible_steamapps {
if path.exists() && path.is_dir() && !libraries.contains(path) { if path.exists() && path.is_dir() && !libraries.contains(path) {
debug!("Found steamapps directory: {}", path.display()); debug!("Found steamapps directory: {}", path.display());
libraries.insert(path.clone()); libraries.insert(path.clone());
// Check for additional libraries in libraryfolders.vdf // Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(path, &mut libraries); parse_library_folders_vdf(path, &mut libraries);
} }
} }
} }
let result: Vec<PathBuf> = libraries.into_iter().collect(); let result: Vec<PathBuf> = libraries.into_iter().collect();
info!("Found {} Steam library directories", result.len()); info!("Found {} Steam library directories", result.len());
for (i, lib) in result.iter().enumerate() { for (i, lib) in result.iter().enumerate() {
info!(" Library {}: {}", i+1, lib.display()); info!(" Library {}: {}", i + 1, lib.display());
} }
result result
} }
/// Parse libraryfolders.vdf to extract additional library paths // Parse libraryfolders.vdf to extract additional library paths
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) { fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
// Check both possible locations of the VDF file // Check both possible locations of the VDF file
let vdf_paths = [ let vdf_paths = [
steamapps_path.join("libraryfolders.vdf"), steamapps_path.join("libraryfolders.vdf"),
steamapps_path.join("config/libraryfolders.vdf"), steamapps_path.join("config/libraryfolders.vdf"),
]; ];
for vdf_path in &vdf_paths { for vdf_path in &vdf_paths {
if vdf_path.exists() { if vdf_path.exists() {
debug!("Found library folders VDF: {}", vdf_path.display()); debug!("Found library folders VDF: {}", vdf_path.display());
if let Ok(content) = fs::read_to_string(vdf_path) { if let Ok(content) = fs::read_to_string(vdf_path) {
// Extract library paths using regex for both new and old format VDFs // Extract library paths using regex for both new and old format VDFs
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap(); let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
for cap in re_path.captures_iter(&content) { for cap in re_path.captures_iter(&content) {
let path_str = &cap[1]; let path_str = &cap[1];
let lib_path = PathBuf::from(path_str).join("steamapps"); let lib_path = PathBuf::from(path_str).join("steamapps");
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) { if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
debug!("Found library from VDF: {}", lib_path.display()); debug!("Found library from VDF: {}", lib_path.display());
// Clone lib_path before inserting to avoid ownership issues // Clone lib_path before inserting to avoid ownership issues
let lib_path_clone = lib_path.clone(); let lib_path_clone = lib_path.clone();
libraries.insert(lib_path_clone); libraries.insert(lib_path_clone);
// Recursively check this library for more libraries // Recursively check this library for more libraries
parse_library_folders_vdf(&lib_path, libraries); parse_library_folders_vdf(&lib_path, libraries);
} }
@@ -199,7 +195,7 @@ fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<Path
} }
} }
/// Parse an appmanifest ACF file to extract game information // Parse an appmanifest ACF file to extract game information
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> { fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(content) => { Ok(content) => {
@@ -207,16 +203,16 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap(); let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap(); let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap(); let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = ( if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
re_appid.captures(&content), re_appid.captures(&content),
re_name.captures(&content), re_name.captures(&content),
re_installdir.captures(&content) re_installdir.captures(&content),
) { ) {
let app_id = app_id_cap[1].to_string(); let app_id = app_id_cap[1].to_string();
let name = name_cap[1].to_string(); let name = name_cap[1].to_string();
let install_dir = dir_cap[1].to_string(); let install_dir = dir_cap[1].to_string();
return Some((app_id, name, install_dir)); return Some((app_id, name, install_dir));
} }
} }
@@ -224,364 +220,387 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
error!("Failed to read ACF file {}: {}", path.display(), e); error!("Failed to read ACF file {}: {}", path.display(), e);
} }
} }
None None
} }
/// Check if a file is a Linux ELF binary // Check if a file is a Linux ELF binary
fn is_elf_binary(path: &Path) -> bool { fn is_elf_binary(path: &Path) -> bool {
if let Ok(mut file) = fs::File::open(path) { if let Ok(mut file) = fs::File::open(path) {
let mut buffer = [0; 4]; let mut buffer = [0; 4];
if file.read_exact(&mut buffer).is_ok() { if file.read_exact(&mut buffer).is_ok() {
// Check for ELF magic number (0x7F 'E' 'L' 'F') // Check for ELF magic number (0x7F 'E' 'L' 'F')
return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F'; return buffer[0] == 0x7F
&& buffer[1] == b'E'
&& buffer[2] == b'L'
&& buffer[3] == b'F';
} }
} }
false false
} }
/// Check if a game has CreamLinux installed // Check if a game has CreamLinux installed
fn check_creamlinux_installed(game_path: &Path) -> bool { fn check_creamlinux_installed(game_path: &Path) -> bool {
let cream_files = [ let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
"cream.sh",
"cream_api.ini",
"cream_api.so",
];
for file in &cream_files { for file in &cream_files {
if game_path.join(file).exists() { if game_path.join(file).exists() {
debug!("CreamLinux installation detected: {}", file); debug!("CreamLinux installation detected: {}", file);
return true; return true;
} }
} }
false false
} }
/// Check if a game has SmokeAPI installed // Check if a game has SmokeAPI installed
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool { fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
if api_files.is_empty() { if api_files.is_empty() {
return false; return false;
} }
// SmokeAPI creates backups with _o.dll suffix // SmokeAPI creates backups with _o.dll suffix
for api_file in api_files { for api_file in api_files {
let api_path = game_path.join(api_file); let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path); let api_dir = api_path.parent().unwrap_or(game_path);
let api_filename = api_path.file_name().unwrap_or_default(); let api_filename = api_path.file_name().unwrap_or_default();
// Check for backup file (original file renamed with _o.dll suffix) // Check for backup file (original file renamed with _o.dll suffix)
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll"); let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
let backup_path = api_dir.join(backup_name); let backup_path = api_dir.join(backup_name);
if backup_path.exists() { if backup_path.exists() {
debug!("SmokeAPI backup file found: {}", backup_path.display()); debug!("SmokeAPI backup file found: {}", backup_path.display());
return true; return true;
} }
} }
false false
} }
/// Scan a game directory to determine if it's native or needs Proton // Scan a game directory to determine if it's native or needs Proton
/// Also collect any Steam API DLLs for potential SmokeAPI installation // Also collect any Steam API DLLs for potential SmokeAPI installation
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) { fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
let mut found_exe = false; let mut found_exe = false;
let mut found_linux_binary = false; let mut found_linux_binary = false;
let mut steam_api_files = Vec::new(); let mut steam_api_files = Vec::new();
// Directories to skip for better performance // Directories to skip for better performance
let skip_dirs = [ let skip_dirs = [
"videos", "video", "movies", "movie", "videos",
"sound", "sounds", "audio", "video",
"textures", "music", "localization", "movies",
"shaders", "logs", "assets/audio", "movie",
"assets/video", "assets/textures" "sound",
]; "sounds",
"audio",
// Only scan to a reasonable depth (avoid extreme recursion) "textures",
const MAX_DEPTH: usize = 8; "music",
"localization",
// File extensions to check for (executable and Steam API files) "shaders",
let exe_extensions = ["exe", "bat", "cmd", "msi"]; "logs",
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"]; "assets/audio",
"assets/video",
// Recursively walk through the game directory with optimized settings "assets/textures",
for entry in WalkDir::new(game_path) ];
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
.follow_links(false) // Don't follow symlinks to prevent cycles // Only scan to a reasonable depth (avoid extreme recursion)
.into_iter() const MAX_DEPTH: usize = 8;
.filter_entry(|e| {
// Skip certain directories for performance // File extensions to check for (executable and Steam API files)
if e.file_type().is_dir() { let exe_extensions = ["exe", "bat", "cmd", "msi"];
let file_name = e.file_name().to_string_lossy().to_lowercase(); let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
if skip_dirs.iter().any(|&dir| file_name == dir) {
debug!("Skipping directory: {}", e.path().display()); // Recursively walk through the game directory
return false; for entry in WalkDir::new(game_path)
} .max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
} .follow_links(false) // Don't follow symlinks to prevent cycles
true .into_iter()
}) .filter_entry(|e| {
.filter_map(Result::ok) { // Skip certain directories for performance
if e.file_type().is_dir() {
let path = entry.path(); let file_name = e.file_name().to_string_lossy().to_lowercase();
if !path.is_file() { if skip_dirs.iter().any(|&dir| file_name == dir) {
continue; debug!("Skipping directory: {}", e.path().display());
} return false;
}
// Check file extension }
if let Some(ext) = path.extension() { true
let ext_str = ext.to_string_lossy().to_lowercase(); })
.filter_map(Result::ok)
// Check for Windows executables {
if exe_extensions.iter().any(|&e| ext_str == e) { let path = entry.path();
found_exe = true; if !path.is_file() {
} continue;
}
// Check for Steam API DLLs
if ext_str == "dll" { // Check file extension
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); if let Some(ext) = path.extension() {
if filename == "steam_api.dll" || filename == "steam_api64.dll" { let ext_str = ext.to_string_lossy().to_lowercase();
if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string(); // Check for Windows executables
debug!("Found Steam API DLL: {}", rel_path_str); if exe_extensions.iter().any(|&e| ext_str == e) {
steam_api_files.push(rel_path_str); found_exe = true;
} }
}
} // Check for Steam API DLLs
if ext_str == "dll" {
// Check for Linux binary files let filename = path
if binary_extensions.iter().any(|&e| ext_str == e) { .file_name()
found_linux_binary = true; .unwrap_or_default()
.to_string_lossy()
// Check if it's actually an ELF binary for more certainty .to_lowercase();
if ext_str == "so" && is_elf_binary(path) { if filename == "steam_api.dll" || filename == "steam_api64.dll" {
found_linux_binary = true; if let Ok(rel_path) = path.strip_prefix(game_path) {
} let rel_path_str = rel_path.to_string_lossy().to_string();
} debug!("Found Steam API DLL: {}", rel_path_str);
} steam_api_files.push(rel_path_str);
}
// Check for Linux executables (no extension) }
#[cfg(unix)] }
if !path.extension().is_some() {
use std::os::unix::fs::PermissionsExt; // Check for Linux binary files
if binary_extensions.iter().any(|&e| ext_str == e) {
if let Ok(metadata) = path.metadata() { found_linux_binary = true;
let is_executable = metadata.permissions().mode() & 0o111 != 0;
// Check if it's actually an ELF binary for more certainty
// Check executable permission and ELF format if ext_str == "so" && is_elf_binary(path) {
if is_executable && is_elf_binary(path) { found_linux_binary = true;
found_linux_binary = true; }
} }
} }
}
// Check for Linux executables (no extension)
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop #[cfg(unix)]
// This early break greatly improves performance for large game directories if !path.extension().is_some() {
if found_exe && found_linux_binary && !steam_api_files.is_empty() { use std::os::unix::fs::PermissionsExt;
debug!("Found sufficient evidence, breaking scan early");
break; if let Ok(metadata) = path.metadata() {
} let is_executable = metadata.permissions().mode() & 0o111 != 0;
}
// Check executable permission and ELF format
// A game is considered native if it has Linux binaries but no Windows executables if is_executable && is_elf_binary(path) {
let is_native = found_linux_binary && !found_exe; found_linux_binary = true;
}
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len()); }
(is_native, steam_api_files) }
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
debug!("Found sufficient evidence, breaking scan early");
break;
}
}
// A game is considered native if it has Linux binaries but no Windows executables
let is_native = found_linux_binary && !found_exe;
debug!(
"Game scan results: native={}, exe={}, api_dlls={}",
is_native,
found_exe,
steam_api_files.len()
);
(is_native, steam_api_files)
} }
/// Find all installed Steam games from library folders // Find all installed Steam games from library folders
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> { pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
let mut games = Vec::new();
let mut games = Vec::new(); let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
// IDs to skip (tools, redistributables, etc.)
// IDs to skip (tools, redistributables, etc.) let skip_ids = Arc::new(
let skip_ids = Arc::new([ [
"228980", // Steamworks Common Redistributables "228980", // Steamworks Common Redistributables
"1070560", // Steam Linux Runtime "1070560", // Steam Linux Runtime
"1391110", // Steam Linux Runtime - Soldier "1391110", // Steam Linux Runtime - Soldier
"1628350", // Steam Linux Runtime - Sniper "1628350", // Steam Linux Runtime - Sniper
"1493710", // Proton Experimental "1493710", // Proton Experimental
"2180100", // Steam Linux Runtime - Scout "2180100", // Steam Linux Runtime - Scout
].iter().copied().collect::<HashSet<&str>>()); ]
.iter()
// Name patterns to skip (case insensitive) .copied()
let skip_patterns = Arc::new( .collect::<HashSet<&str>>(),
[ );
r"(?i)steam linux runtime",
r"(?i)proton", // Name patterns to skip (case insensitive)
r"(?i)steamworks common", let skip_patterns = Arc::new(
r"(?i)redistributable", [
r"(?i)dotnet", r"(?i)steam linux runtime",
r"(?i)vc redist", r"(?i)proton",
] r"(?i)steamworks common",
.iter() r"(?i)redistributable",
.map(|pat| Regex::new(pat).unwrap()) r"(?i)dotnet",
.collect::<Vec<_>>() r"(?i)vc redist",
); ]
.iter()
info!("Scanning for installed games in parallel..."); .map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>(),
// Create a channel to collect results );
let (tx, mut rx) = mpsc::channel(32);
info!("Scanning for installed games in parallel...");
// First collect all appmanifest files to process
let mut app_manifests = Vec::new(); // Create a channel to collect results
for steamapps_dir in steamapps_paths { let (tx, mut rx) = mpsc::channel(32);
if let Ok(entries) = fs::read_dir(steamapps_dir) {
for entry in entries.flatten() { // First collect all appmanifest files to process
let path = entry.path(); let mut app_manifests = Vec::new();
let filename = path.file_name().unwrap_or_default().to_string_lossy(); for steamapps_dir in steamapps_paths {
if let Ok(entries) = fs::read_dir(steamapps_dir) {
// Check for appmanifest files for entry in entries.flatten() {
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") { let path = entry.path();
app_manifests.push((path, steamapps_dir.clone())); let filename = path.file_name().unwrap_or_default().to_string_lossy();
}
} // Check for appmanifest files
} if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
} app_manifests.push((path, steamapps_dir.clone()));
}
info!("Found {} appmanifest files to process", app_manifests.len()); }
}
// Process each appmanifest file in parallel with a maximum concurrency }
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent); info!("Found {} appmanifest files to process", app_manifests.len());
// Use a semaphore to limit concurrency // Process appmanifest files
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent);
// Create a Vec to store all our task handles
let mut handles = Vec::new(); // Use a semaphore to limit concurrency
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
// Process each manifest file
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() { // Create a Vec to store all our task handles
// Clone what we need for the task let mut handles = Vec::new();
let path = path.clone();
let steamapps_dir = steamapps_dir.clone(); // Process each manifest file
let skip_patterns = Arc::clone(&skip_patterns); for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
let tx = tx.clone(); // Clone what we need for the task
let seen_ids = Arc::clone(&seen_ids); let path = path.clone();
let semaphore = Arc::clone(&semaphore); let steamapps_dir = steamapps_dir.clone();
let skip_ids = Arc::clone(&skip_ids); let skip_patterns = Arc::clone(&skip_patterns);
let tx = tx.clone();
// Create a new task let seen_ids = Arc::clone(&seen_ids);
let handle = tokio::spawn(async move { let semaphore = Arc::clone(&semaphore);
// Acquire a permit from the semaphore let skip_ids = Arc::clone(&skip_ids);
let _permit = semaphore.acquire().await.unwrap();
// Create a new task
// Parse the appmanifest file let handle = tokio::spawn(async move {
if let Some((id, name, install_dir)) = parse_appmanifest(&path) { // Acquire a permit from the semaphore
// Skip if in exclusion list let _permit = semaphore.acquire().await.unwrap();
if skip_ids.contains(id.as_str()) {
return; // Parse the appmanifest file
} if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
// Skip if in exclusion list
// Add a guard against duplicates if skip_ids.contains(id.as_str()) {
{ return;
let mut seen = seen_ids.lock().await; }
if seen.contains(&id) {
return; // Add a guard against duplicates
} {
seen.insert(id.clone()); let mut seen = seen_ids.lock().await;
} if seen.contains(&id) {
return;
// Skip if the name matches any exclusion patterns }
if skip_patterns.iter().any(|re| re.is_match(&name)) { seen.insert(id.clone());
debug!("Skipping runtime/tool: {} ({})", name, id); }
return;
} // Skip if the name matches any exclusion patterns
if skip_patterns.iter().any(|re| re.is_match(&name)) {
// Full path to the game directory debug!("Skipping runtime/tool: {} ({})", name, id);
let game_path = steamapps_dir.join("common").join(&install_dir); return;
}
// Skip if game directory doesn't exist
if !game_path.exists() { // Full path to the game directory
warn!("Game directory not found: {}", game_path.display()); let game_path = steamapps_dir.join("common").join(&install_dir);
return;
} // Skip if game directory doesn't exist
if !game_path.exists() {
// Scan the game directory to determine platform and find Steam API DLLs warn!("Game directory not found: {}", game_path.display());
info!("Scanning game: {} at {}", name, game_path.display()); return;
}
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
let (is_native, api_files) = scan_game_directory(&game_path); // Scan the game directory to determine platform and find Steam API DLLs
info!("Scanning game: {} at {}", name, game_path.display());
// Check for CreamLinux installation
let cream_installed = check_creamlinux_installed(&game_path); // Scanning is I/O heavy but not CPU heavy, so we can just do it directly
let (is_native, api_files) = scan_game_directory(&game_path);
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
let smoke_installed = if !is_native && !api_files.is_empty() { // Check for CreamLinux installation
check_smokeapi_installed(&game_path, &api_files) let cream_installed = check_creamlinux_installed(&game_path);
} else {
false // Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
}; let smoke_installed = if !is_native && !api_files.is_empty() {
check_smokeapi_installed(&game_path, &api_files)
// Create the game info } else {
let game_info = GameInfo { false
id, };
title: name,
path: game_path, // Create the game info
native: is_native, let game_info = GameInfo {
api_files, id,
cream_installed, title: name,
smoke_installed, path: game_path,
}; native: is_native,
api_files,
// Send the game info through the channel cream_installed,
if tx.send(game_info).await.is_err() { smoke_installed,
error!("Failed to send game info through channel"); };
}
} // Send the game info through the channel
}); if tx.send(game_info).await.is_err() {
error!("Failed to send game info through channel");
handles.push(handle); }
}
// Every 10 files, yield to allow progress updates });
if manifest_idx % 10 == 0 {
// We would update progress here in a full implementation handles.push(handle);
tokio::task::yield_now().await;
} // Every 10 files, yield to allow progress updates
} if manifest_idx % 10 == 0 {
// We would update progress here in a full implementation
// Drop the original sender so the receiver knows when we're done tokio::task::yield_now().await;
drop(tx); }
}
// Spawn a task to collect all the results
let receiver_task = tokio::spawn(async move { // Drop the original sender so the receiver knows when we're done
let mut results = Vec::new(); drop(tx);
while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id); // Spawn a task to collect all the results
info!(" Path: {}", game.path.display()); let receiver_task = tokio::spawn(async move {
info!(" Status: Native={}, Cream={}, Smoke={}", let mut results = Vec::new();
game.native, game.cream_installed, game.smoke_installed); while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id);
// Log Steam API DLLs if any info!(" Path: {}", game.path.display());
if !game.api_files.is_empty() { info!(
info!(" Steam API files:"); " Status: Native={}, Cream={}, Smoke={}",
for api_file in &game.api_files { game.native, game.cream_installed, game.smoke_installed
info!(" - {}", api_file); );
}
} // Log Steam API DLLs if any
if !game.api_files.is_empty() {
results.push(game); info!(" Steam API files:");
} for api_file in &game.api_files {
results info!(" - {}", api_file);
}); }
}
// Wait for all scan tasks to complete - but don't wait for the results yet
for handle in handles { results.push(game);
// Ignore errors - the receiver task will just get fewer results }
let _ = handle.await; results
} });
// Now wait for all results to be collected // Wait for all scan tasks to complete but don't wait for the results yet
if let Ok(results) = receiver_task.await { for handle in handles {
games = results; // Ignore errors the receiver task will just get fewer results
} let _ = handle.await;
}
info!("Found {} installed games", games.len());
games // Now wait for all results to be collected
} if let Ok(results) = receiver_task.await {
games = results;
}
info!("Found {} installed games", games.len());
games
}

View File

@@ -10,11 +10,7 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"category": "Utility", "category": "Utility",
"icon": [ "icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
]
}, },
"productName": "Creamlinux", "productName": "Creamlinux",
"mainBinaryName": "creamlinux", "mainBinaryName": "creamlinux",

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,41 @@
// src/components/ActionButton.tsx import React from 'react'
import React from 'react';
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'; export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
interface ActionButtonProps { interface ActionButtonProps {
action: ActionType; action: ActionType
isInstalled: boolean; isInstalled: boolean
isWorking: boolean; isWorking: boolean
onClick: () => void; onClick: () => void
disabled?: boolean; disabled?: boolean
} }
const ActionButton: React.FC<ActionButtonProps> = ({ const ActionButton: React.FC<ActionButtonProps> = ({
action, action,
isInstalled, isInstalled,
isWorking, isWorking,
onClick, onClick,
disabled = false disabled = false,
}) => { }) => {
const getButtonText = () => { const getButtonText = () => {
if (isWorking) return "Working..."; if (isWorking) return 'Working...'
const isCream = action.includes('cream'); const isCream = action.includes('cream')
const product = isCream ? "CreamLinux" : "SmokeAPI"; const product = isCream ? 'CreamLinux' : 'SmokeAPI'
return isInstalled ? `Uninstall ${product}` : `Install ${product}`; return isInstalled ? `Uninstall ${product}` : `Install ${product}`
}; }
const getButtonClass = () => { const getButtonClass = () => {
const baseClass = "action-button"; const baseClass = 'action-button'
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`; return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`
}; }
return ( return (
<button <button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
className={getButtonClass()}
onClick={onClick}
disabled={disabled || isWorking}
>
{getButtonText()} {getButtonText()}
</button> </button>
); )
}; }
export default ActionButton; export default ActionButton

View File

@@ -1,46 +1,45 @@
// src/components/AnimatedBackground.tsx import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react';
const AnimatedBackground: React.FC = () => { const AnimatedBackground: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current
if (!canvas) return; if (!canvas) return
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d')
if (!ctx) return; if (!ctx) return
// Set canvas size to match window // Set canvas size to match window
const setCanvasSize = () => { const setCanvasSize = () => {
canvas.width = window.innerWidth; canvas.width = window.innerWidth
canvas.height = window.innerHeight; canvas.height = window.innerHeight
};
setCanvasSize();
window.addEventListener('resize', setCanvasSize);
// Create particles
const particles: Particle[] = [];
const particleCount = 30;
interface Particle {
x: number;
y: number;
size: number;
speedX: number;
speedY: number;
opacity: number;
color: string;
} }
setCanvasSize()
window.addEventListener('resize', setCanvasSize)
// Create particles
const particles: Particle[] = []
const particleCount = 30
interface Particle {
x: number
y: number
size: number
speedX: number
speedY: number
opacity: number
color: string
}
// Color palette // Color palette
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
'rgba(251, 177, 60, 0.5)', // gold 'rgba(251, 177, 60, 0.5)', // gold
]; ]
// Create initial particles // Create initial particles
for (let i = 0; i < particleCount; i++) { for (let i = 0; i < particleCount; i++) {
particles.push({ particles.push({
@@ -50,65 +49,65 @@ const AnimatedBackground: React.FC = () => {
speedX: Math.random() * 0.2 - 0.1, speedX: Math.random() * 0.2 - 0.1,
speedY: Math.random() * 0.2 - 0.1, speedY: Math.random() * 0.2 - 0.1,
opacity: Math.random() * 0.07 + 0.03, opacity: Math.random() * 0.07 + 0.03,
color: colors[Math.floor(Math.random() * colors.length)] color: colors[Math.floor(Math.random() * colors.length)],
}); })
} }
// Animation loop // Animation loop
const animate = () => { const animate = () => {
// Clear canvas with transparent black to create fade effect // Clear canvas with transparent black to create fade effect
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'; ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height)
// Update and draw particles // Update and draw particles
particles.forEach(particle => { particles.forEach((particle) => {
// Update position // Update position
particle.x += particle.speedX; particle.x += particle.speedX
particle.y += particle.speedY; particle.y += particle.speedY
// Wrap around edges // Wrap around edges
if (particle.x < 0) particle.x = canvas.width; if (particle.x < 0) particle.x = canvas.width
if (particle.x > canvas.width) particle.x = 0; if (particle.x > canvas.width) particle.x = 0
if (particle.y < 0) particle.y = canvas.height; if (particle.y < 0) particle.y = canvas.height
if (particle.y > canvas.height) particle.y = 0; if (particle.y > canvas.height) particle.y = 0
// Draw particle // Draw particle
ctx.beginPath(); ctx.beginPath()
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
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
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
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 100) { if (distance < 100) {
ctx.beginPath(); ctx.beginPath()
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`); ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
ctx.lineWidth = 0.2; ctx.lineWidth = 0.2
ctx.moveTo(particle.x, particle.y); ctx.moveTo(particle.x, particle.y)
ctx.lineTo(otherParticle.x, otherParticle.y); ctx.lineTo(otherParticle.x, otherParticle.y)
ctx.stroke(); ctx.stroke()
} }
}); })
}); })
requestAnimationFrame(animate); requestAnimationFrame(animate)
}; }
// Start animation // Start animation
animate(); animate()
return () => { return () => {
window.removeEventListener('resize', setCanvasSize); window.removeEventListener('resize', setCanvasSize)
}; }
}, []); }, [])
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="animated-background" className="animated-background"
style={{ style={{
position: 'fixed', position: 'fixed',
@@ -118,10 +117,10 @@ const AnimatedBackground: React.FC = () => {
height: '100%', height: '100%',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 0, zIndex: 0,
opacity: 0.4 opacity: 0.4,
}} }}
/> />
); )
}; }
export default AnimatedBackground; export default AnimatedBackground

View File

@@ -1,12 +1,11 @@
// src/components/AnimatedCheckbox.tsx import React from 'react'
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> = ({ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
@@ -14,25 +13,20 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
onChange, onChange,
label, label,
sublabel, sublabel,
className = '' className = '',
}) => { }) => {
return ( return (
<label className={`animated-checkbox ${className}`}> <label className={`animated-checkbox ${className}`}>
<input <input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
type="checkbox"
checked={checked}
onChange={onChange}
className="checkbox-original"
/>
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}> <span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
<svg viewBox="0 0 24 24" className="checkmark-icon"> <svg viewBox="0 0 24 24" className="checkmark-icon">
<path <path
className={`checkmark ${checked ? 'checked' : ''}`} className={`checkmark ${checked ? 'checked' : ''}`}
d="M5 12l5 5L20 7" d="M5 12l5 5L20 7"
stroke="#fff" stroke="#fff"
strokeWidth="2.5" strokeWidth="2.5"
fill="none" fill="none"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
@@ -44,7 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
</div> </div>
)} )}
</label> </label>
); )
}; }
export default AnimatedCheckbox; export default AnimatedCheckbox

View File

@@ -1,23 +1,22 @@
// src/components/DlcSelectionDialog.tsx import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'; import AnimatedCheckbox from './AnimatedCheckbox'
import AnimatedCheckbox from './AnimatedCheckbox';
interface DlcInfo { interface DlcInfo {
appid: string; appid: string
name: string; name: string
enabled: boolean; enabled: boolean
} }
interface DlcSelectionDialogProps { interface DlcSelectionDialogProps {
visible: boolean; visible: boolean
gameTitle: string; gameTitle: string
dlcs: DlcInfo[]; dlcs: DlcInfo[]
onClose: () => void; onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void; onConfirm: (selectedDlcs: DlcInfo[]) => void
isLoading: boolean; isLoading: boolean
isEditMode?: boolean; isEditMode?: boolean
loadingProgress?: number; loadingProgress?: number
estimatedTimeLeft?: string; estimatedTimeLeft?: string
} }
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
@@ -29,122 +28,125 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
isLoading, isLoading,
isEditMode = false, isEditMode = false,
loadingProgress = 0, loadingProgress = 0,
estimatedTimeLeft = '' estimatedTimeLeft = '',
}) => { }) => {
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]); const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false)
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true); const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false)
// Initialize selected DLCs when DLC list changes // Initialize selected DLCs when DLC list changes
useEffect(() => { useEffect(() => {
if (visible && dlcs.length > 0 && !initialized) { if (visible && dlcs.length > 0 && !initialized) {
setSelectedDlcs(dlcs); setSelectedDlcs(dlcs)
// Determine initial selectAll state based on if all DLCs are enabled // Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every(dlc => dlc.enabled); const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected); setSelectAll(allSelected)
// Mark as initialized so we don't reset selections on subsequent DLC additions // Mark as initialized so we don't reset selections on subsequent DLC additions
setInitialized(true); setInitialized(true)
} }
}, [visible, dlcs, initialized]); }, [visible, dlcs, initialized])
// Handle visibility changes // Handle visibility changes
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// Show content immediately for better UX // Show content immediately for better UX
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowContent(true); setShowContent(true)
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} else { } else {
setShowContent(false); setShowContent(false)
setInitialized(false); // Reset initialized state when dialog closes setInitialized(false) // Reset initialized state when dialog closes
} }
}, [visible]); }, [visible])
// Memoize filtered DLCs to avoid unnecessary recalculations // Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = useMemo(() => { const filteredDlcs = useMemo(() => {
return searchQuery.trim() === '' return searchQuery.trim() === ''
? selectedDlcs ? selectedDlcs
: selectedDlcs.filter(dlc => : selectedDlcs.filter(
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) || (dlc) =>
dlc.appid.includes(searchQuery) dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
); dlc.appid.includes(searchQuery)
}, [selectedDlcs, searchQuery]); )
}, [selectedDlcs, searchQuery])
// Update DLC selection status // Update DLC selection status
const handleToggleDlc = (appid: string) => { const handleToggleDlc = (appid: string) => {
setSelectedDlcs(prev => prev.map(dlc => setSelectedDlcs((prev) =>
dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
)); )
}; }
// Update selectAll state when individual DLC selections change // Update selectAll state when individual DLC selections change
useEffect(() => { useEffect(() => {
const allSelected = selectedDlcs.every(dlc => dlc.enabled); const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected); setSelectAll(allSelected)
}, [selectedDlcs]); }, [selectedDlcs])
// Handle new DLCs being added while dialog is already open // Handle new DLCs being added while dialog is already open
useEffect(() => { useEffect(() => {
if (initialized && dlcs.length > selectedDlcs.length) { if (initialized && dlcs.length > selectedDlcs.length) {
// Find new DLCs that aren't in our current selection // Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid)); const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid)); const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// Add new DLCs to our selection, maintaining their enabled state // Add new DLCs to our selection, maintaining their enabled state
if (newDlcs.length > 0) { if (newDlcs.length > 0) {
setSelectedDlcs(prev => [...prev, ...newDlcs]); setSelectedDlcs((prev) => [...prev, ...newDlcs])
} }
} }
}, [dlcs, selectedDlcs, initialized]); }, [dlcs, selectedDlcs, initialized])
const handleToggleSelectAll = () => { const handleToggleSelectAll = () => {
const newSelectAllState = !selectAll; const newSelectAllState = !selectAll
setSelectAll(newSelectAllState); setSelectAll(newSelectAllState)
setSelectedDlcs(prev => prev.map(dlc => ({ setSelectedDlcs((prev) =>
...dlc, prev.map((dlc) => ({
enabled: newSelectAllState ...dlc,
}))); enabled: newSelectAllState,
}; }))
)
}
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(selectedDlcs); onConfirm(selectedDlcs)
}; }
// Modified to prevent closing when loading // Modified to prevent closing when loading
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Prevent clicks from propagating through the overlay // Prevent clicks from propagating through the overlay
e.stopPropagation(); e.stopPropagation()
// Only allow closing via overlay click if not loading // Only allow closing via overlay click if not loading
if (e.target === e.currentTarget && !isLoading) { if (e.target === e.currentTarget && !isLoading) {
onClose(); onClose()
} }
}; }
// Count selected DLCs // Count selected DLCs
const selectedCount = selectedDlcs.filter(dlc => dlc.enabled).length; const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
// Format loading message to show total number of DLCs found // Format loading message to show total number of DLCs found
const getLoadingInfoText = () => { const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) { if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`; return ` (Loading more DLCs...)`
} else if (dlcs.length > 0) { } else if (dlcs.length > 0) {
return ` (Total DLCs: ${dlcs.length})`; return ` (Total DLCs: ${dlcs.length})`
} }
return ''; return ''
}; }
if (!visible) return null; if (!visible) return null
return ( return (
<div <div
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`} className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}> <div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}>
@@ -160,9 +162,9 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
</div> </div>
<div className="dlc-dialog-search"> <div className="dlc-dialog-search">
<input <input
type="text" type="text"
placeholder="Search DLCs..." placeholder="Search DLCs..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input" className="dlc-search-input"
@@ -179,14 +181,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
{isLoading && ( {isLoading && (
<div className="dlc-loading-progress"> <div className="dlc-loading-progress">
<div className="progress-bar-container"> <div className="progress-bar-container">
<div <div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
className="progress-bar"
style={{ width: `${loadingProgress}%` }}
/>
</div> </div>
<div className="loading-details"> <div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span> <span>Loading DLCs: {loadingProgress}%</span>
{estimatedTimeLeft && <span className="time-left">Est. time left: {estimatedTimeLeft}</span>} {estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div> </div>
</div> </div>
)} )}
@@ -194,7 +195,7 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
<div className="dlc-list-container"> <div className="dlc-list-container">
{selectedDlcs.length > 0 ? ( {selectedDlcs.length > 0 ? (
<ul className="dlc-list"> <ul className="dlc-list">
{filteredDlcs.map(dlc => ( {filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item"> <li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox <AnimatedCheckbox
checked={dlc.enabled} checked={dlc.enabled}
@@ -219,24 +220,20 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
</div> </div>
<div className="dlc-dialog-actions"> <div className="dlc-dialog-actions">
<button <button
className="cancel-button" className="cancel-button"
onClick={onClose} onClick={onClose}
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
> >
Cancel Cancel
</button> </button>
<button <button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
className="confirm-button"
onClick={handleConfirm}
disabled={isLoading}
>
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'} {isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default DlcSelectionDialog; export default DlcSelectionDialog

View File

@@ -1,98 +1,100 @@
// src/components/GameItem.tsx import React, { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'; import { findBestGameImage } from '../services/ImageService'
import { findBestGameImage } from '../services/ImageService'; import { ActionType } from './ActionButton'
import { ActionType } from './ActionButton';
interface Game { interface Game {
id: string; id: string
title: string; title: string
path: string; path: string
platform?: string; platform?: string
native: boolean; native: boolean
api_files: string[]; api_files: string[]
cream_installed?: boolean; cream_installed?: boolean
smoke_installed?: boolean; smoke_installed?: boolean
installing?: boolean; installing?: boolean
} }
interface GameItemProps { interface GameItemProps {
game: Game; game: Game
onAction: (gameId: string, action: ActionType) => Promise<void>; onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void; onEdit?: (gameId: string) => void
} }
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => { const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
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)
useEffect(() => { useEffect(() => {
// Function to fetch the game cover/image // Function to fetch the game cover/image
const fetchGameImage = async () => { const fetchGameImage = async () => {
// First check if we already have it (to prevent flickering on re-renders) // First check if we already have it (to prevent flickering on re-renders)
if (imageUrl) return; if (imageUrl) return
setIsLoading(true); setIsLoading(true)
try { try {
// Try to find the best available image for this game // Try to find the best available image for this game
const bestImageUrl = await findBestGameImage(game.id); const bestImageUrl = await findBestGameImage(game.id)
if (bestImageUrl) { if (bestImageUrl) {
setImageUrl(bestImageUrl); setImageUrl(bestImageUrl)
setHasError(false); setHasError(false)
} else { } else {
setHasError(true); setHasError(true)
} }
} catch (error) { } catch (error) {
console.error('Error fetching game image:', error); console.error('Error fetching game image:', error)
setHasError(true); setHasError(true)
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}; }
if (game.id) { if (game.id) {
fetchGameImage(); fetchGameImage()
} }
}, [game.id, imageUrl]); }, [game.id, imageUrl])
// Determine if we should show CreamLinux buttons (only for native games) // Determine if we should show CreamLinux buttons (only for native games)
const shouldShowCream = game.native === true; const shouldShowCream = game.native === true
// Determine if we should show SmokeAPI buttons (only for non-native games with API files) // Determine if we should show SmokeAPI buttons (only for non-native games with API files)
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0; const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
// Check if this is a Proton game without API files // Check if this is a Proton game without API files
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0); const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
const handleCreamAction = () => { const handleCreamAction = () => {
if (game.installing) return; if (game.installing) return
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'; const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
onAction(game.id, action); onAction(game.id, action)
}; }
const handleSmokeAction = () => { const handleSmokeAction = () => {
if (game.installing) return; if (game.installing) return
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'; const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
onAction(game.id, action); onAction(game.id, action)
}; }
// Handle edit button click // Handle edit button click
const handleEdit = () => { const handleEdit = () => {
if (onEdit && game.cream_installed) { if (onEdit && game.cream_installed) {
onEdit(game.id); onEdit(game.id)
} }
}; }
// Determine background image // Determine background image
const backgroundImage = !isLoading && imageUrl ? const backgroundImage =
`url(${imageUrl})` : !isLoading && imageUrl
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)'; ? `url(${imageUrl})`
: hasError
? 'linear-gradient(135deg, #232323, #1A1A1A)'
: 'linear-gradient(135deg, #232323, #1A1A1A)'
return ( return (
<div <div
className="game-item-card" className="game-item-card"
style={{ style={{
backgroundImage, backgroundImage,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
@@ -103,14 +105,10 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}> <span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
{game.native ? 'Native' : 'Proton'} {game.native ? 'Native' : 'Proton'}
</span> </span>
{game.cream_installed && ( {game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
<span className="status-badge cream">CreamLinux</span> {game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
)}
{game.smoke_installed && (
<span className="status-badge smoke">SmokeAPI</span>
)}
</div> </div>
<div className="game-title"> <div className="game-title">
<h3>{game.title}</h3> <h3>{game.title}</h3>
</div> </div>
@@ -118,31 +116,39 @@ 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 <button
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`} className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
onClick={handleCreamAction} onClick={handleCreamAction}
disabled={!!game.installing} disabled={!!game.installing}
> >
{game.installing ? "Working..." : (game.cream_installed ? "Uninstall CreamLinux" : "Install CreamLinux")} {game.installing
? 'Working...'
: game.cream_installed
? 'Uninstall CreamLinux'
: 'Install CreamLinux'}
</button> </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 <button
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`} className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
onClick={handleSmokeAction} onClick={handleSmokeAction}
disabled={!!game.installing} disabled={!!game.installing}
> >
{game.installing ? "Working..." : (game.smoke_installed ? "Uninstall SmokeAPI" : "Install SmokeAPI")} {game.installing
? 'Working...'
: game.smoke_installed
? 'Uninstall SmokeAPI'
: 'Install SmokeAPI'}
</button> </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" className="rescan-button"
onClick={() => onAction(game.id, 'install_smoke')} onClick={() => onAction(game.id, 'install_smoke')}
title="Attempt to scan again" title="Attempt to scan again"
@@ -151,10 +157,10 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
</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" className="edit-button"
onClick={handleEdit} onClick={handleEdit}
disabled={!game.cream_installed || !!game.installing} disabled={!game.cream_installed || !!game.installing}
@@ -166,7 +172,7 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default GameItem; export default GameItem

View File

@@ -1,92 +1,81 @@
// src/components/GameList.tsx import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'; import GameItem from './GameItem'
import GameItem from './GameItem'; import ImagePreloader from './ImagePreloader'
import ImagePreloader from './ImagePreloader'; import { ActionType } from './ActionButton'
import { ActionType } from './ActionButton';
interface Game { interface Game {
id: string; id: string
title: string; title: string
path: string; path: string
platform?: string; platform?: string
native: boolean; native: boolean
api_files: string[]; api_files: string[]
cream_installed?: boolean; cream_installed?: boolean
smoke_installed?: boolean; smoke_installed?: boolean
installing?: boolean; installing?: boolean
} }
interface GameListProps { interface GameListProps {
games: Game[]; games: Game[]
isLoading: boolean; isLoading: boolean
onAction: (gameId: string, action: ActionType) => Promise<void>; onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void; onEdit?: (gameId: string) => void
} }
const GameList: React.FC<GameListProps> = ({ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
games, const [imagesPreloaded, setImagesPreloaded] = useState(false)
isLoading,
onAction, // Sort games alphabetically by title using useMemo to avoid re-sorting on each render
onEdit
}) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false);
// Sort games alphabetically by title - using useMemo to avoid re-sorting on each render
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])
// Reset preloaded state when games change // Reset preloaded state when games change
useEffect(() => { useEffect(() => {
setImagesPreloaded(false); setImagesPreloaded(false)
}, [games]); }, [games])
// Debug log to help diagnose game states // Debug log to help diagnose game states
useEffect(() => { useEffect(() => {
if (games.length > 0) { if (games.length > 0) {
console.log("Games state in GameList:", games.length, "games"); console.log('Games state in GameList:', games.length, 'games')
} }
}, [games]); }, [games])
if (isLoading) { if (isLoading) {
return ( return (
<div className="game-list"> <div className="game-list">
<div className="loading-indicator">Scanning for games...</div> <div className="loading-indicator">Scanning for games...</div>
</div> </div>
); )
} }
const handlePreloadComplete = () => { const handlePreloadComplete = () => {
setImagesPreloaded(true); setImagesPreloaded(true)
}; }
return ( return (
<div className="game-list"> <div className="game-list">
<h2>Games ({games.length})</h2> <h2>Games ({games.length})</h2>
{!imagesPreloaded && games.length > 0 && ( {!imagesPreloaded && games.length > 0 && (
<ImagePreloader <ImagePreloader
gameIds={sortedGames.map(game => game.id)} gameIds={sortedGames.map((game) => game.id)}
onComplete={handlePreloadComplete} onComplete={handlePreloadComplete}
/> />
)} )}
{games.length === 0 ? ( {games.length === 0 ? (
<div className="no-games-message">No games found</div> <div className="no-games-message">No games found</div>
) : ( ) : (
<div className="game-grid"> <div className="game-grid">
{sortedGames.map(game => ( {sortedGames.map((game) => (
<GameItem <GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
key={game.id}
game={game}
onAction={onAction}
onEdit={onEdit}
/>
))} ))}
</div> </div>
)} )}
</div> </div>
); )
}; }
export default GameList; export default GameList

View File

@@ -1,40 +1,35 @@
// src/components/Header.tsx import React from 'react'
import React from 'react';
interface HeaderProps { interface HeaderProps {
onRefresh: () => void; onRefresh: () => void
refreshDisabled?: boolean; refreshDisabled?: boolean
onSearch: (query: string) => void; onSearch: (query: string) => void
searchQuery: string; searchQuery: string
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
onRefresh, onRefresh,
refreshDisabled = false, refreshDisabled = false,
onSearch, onSearch,
searchQuery searchQuery,
}) => { }) => {
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 <button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
className="refresh-button"
onClick={onRefresh}
disabled={refreshDisabled}
>
Refresh Refresh
</button> </button>
<input <input
type="text" type="text"
placeholder="Search games..." placeholder="Search games..."
className="search-input" className="search-input"
value={searchQuery} value={searchQuery}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
/> />
</div> </div>
</header> </header>
); )
}; }
export default Header; export default Header

View File

@@ -1,10 +1,9 @@
// src/components/ImagePreloader.tsx import React, { useEffect } from 'react'
import React, { useEffect } from 'react'; import { findBestGameImage } from '../services/ImageService'
import { findBestGameImage } from '../services/ImageService';
interface ImagePreloaderProps { interface ImagePreloaderProps {
gameIds: string[]; gameIds: string[]
onComplete?: () => void; onComplete?: () => void
} }
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => { const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
@@ -12,37 +11,31 @@ const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete })
const preloadImages = async () => { const preloadImages = async () => {
try { try {
// Only preload the first batch for performance (10 images max) // Only preload the first batch for performance (10 images max)
const batchToPreload = gameIds.slice(0, 10); const batchToPreload = gameIds.slice(0, 10)
// Load images in parallel // Load images in parallel
await Promise.allSettled( await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id)))
batchToPreload.map(id => findBestGameImage(id))
);
if (onComplete) { if (onComplete) {
onComplete(); onComplete()
} }
} catch (error) { } catch (error) {
console.error("Error preloading images:", error); console.error('Error preloading images:', error)
// Continue even if there's an error // Continue even if there's an error
if (onComplete) { if (onComplete) {
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; 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

View File

@@ -1,14 +1,11 @@
import React from 'react'; import React from 'react'
interface InitialLoadingScreenProps { interface InitialLoadingScreenProps {
message: string; message: string
progress: number; progress: number
} }
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
message,
progress
}) => {
return ( return (
<div className="initial-loading-screen"> <div className="initial-loading-screen">
<div className="loading-content"> <div className="loading-content">
@@ -22,15 +19,12 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
</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 <div className="progress-bar" style={{ width: `${progress}%` }} />
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div> </div>
<div className="progress-percentage">{Math.round(progress)}%</div> <div className="progress-percentage">{Math.round(progress)}%</div>
</div> </div>
</div> </div>
); )
}; }
export default InitialLoadingScreen; export default InitialLoadingScreen

View File

@@ -1,100 +1,100 @@
// src/components/ProgressDialog.tsx import React, { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react';
interface InstructionInfo { interface InstructionInfo {
type: string; type: string
command: string; command: string
game_title: string; game_title: string
dlc_count?: number; dlc_count?: number
} }
interface ProgressDialogProps { interface ProgressDialogProps {
title: string; title: string
message: string; message: string
progress: number; // 0-100 progress: number // 0-100
visible: boolean; visible: boolean
showInstructions?: boolean; showInstructions?: boolean
instructions?: InstructionInfo; instructions?: InstructionInfo
onClose?: () => void; onClose?: () => void
} }
const ProgressDialog: React.FC<ProgressDialogProps> = ({ const ProgressDialog: React.FC<ProgressDialogProps> = ({
title, title,
message, message,
progress, progress,
visible, visible,
showInstructions = false, showInstructions = false,
instructions, instructions,
onClose onClose,
}) => { }) => {
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false)
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false)
// Reset copy state when dialog visibility changes // Reset copy state when dialog visibility changes
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setCopySuccess(false); setCopySuccess(false)
setShowContent(false); setShowContent(false)
} else { } else {
// Add a small delay to trigger the entrance animation // Add a small delay to trigger the entrance animation
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowContent(true); setShowContent(true)
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
const handleCopyCommand = () => { const handleCopyCommand = () => {
if (instructions?.command) { if (instructions?.command) {
navigator.clipboard.writeText(instructions.command); navigator.clipboard.writeText(instructions.command)
setCopySuccess(true); setCopySuccess(true)
// Reset the success message after 2 seconds // Reset the success message after 2 seconds
setTimeout(() => { setTimeout(() => {
setCopySuccess(false); setCopySuccess(false)
}, 2000); }, 2000)
} }
}; }
const handleClose = () => { const handleClose = () => {
setShowContent(false); setShowContent(false)
// Delay closing to allow exit animation // Delay closing to allow exit animation
setTimeout(() => { setTimeout(() => {
if (onClose) { if (onClose) {
onClose(); onClose()
} }
}, 300); }, 300)
}; }
// Modified to prevent closing when in progress // Prevent closing when in progress
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Always prevent propagation // Always prevent propagation
e.stopPropagation(); e.stopPropagation()
// Only allow clicking outside to close if we're done processing (100%) // Only allow clicking outside to close if we're done processing (100%)
// and showing instructions or if explicitly allowed via a prop // and showing instructions or if explicitly allowed via a prop
if (e.target === e.currentTarget && progress >= 100 && showInstructions) { if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
handleClose(); handleClose()
} }
// Otherwise, do nothing - require using the close button // 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 = instructions?.type === 'cream_install' || const showCopyButton =
instructions?.type === 'cream_uninstall'; instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
// Format instruction message based on type // Format instruction message based on type
const getInstructionText = () => { const getInstructionText = () => {
if (!instructions) return null; if (!instructions) return null
switch (instructions.type) { switch (instructions.type) {
case 'cream_install': case 'cream_install':
return ( return (
<> <>
<p className="instruction-text"> <p className="instruction-text">
In Steam, set the following launch options for <strong>{instructions.game_title}</strong>: In Steam, set the following launch options for{' '}
<strong>{instructions.game_title}</strong>:
</p> </p>
{instructions.dlc_count !== undefined && ( {instructions.dlc_count !== undefined && (
<div className="dlc-count"> <div className="dlc-count">
@@ -102,13 +102,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
</div> </div>
)} )}
</> </>
); )
case 'cream_uninstall': case 'cream_uninstall':
return ( return (
<p className="instruction-text"> <p className="instruction-text">
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the following launch option: For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
following launch option:
</p> </p>
); )
case 'smoke_install': case 'smoke_install':
return ( return (
<> <>
@@ -121,71 +122,67 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
</div> </div>
)} )}
</> </>
); )
case 'smoke_uninstall': case 'smoke_uninstall':
return ( return (
<p className="instruction-text"> <p className="instruction-text">
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong> SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
</p> </p>
); )
default: default:
return ( return (
<p className="instruction-text"> <p className="instruction-text">
Done processing <strong>{instructions.game_title}</strong> Done processing <strong>{instructions.game_title}</strong>
</p> </p>
); )
} }
}; }
// Determine the CSS class for the command box based on instruction type // Determine the CSS class for the command box based on instruction type
const getCommandBoxClass = () => { const getCommandBoxClass = () => {
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'; return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
}; }
// Determine if close button should be enabled // Determine if close button should be enabled
const isCloseButtonEnabled = showInstructions || progress >= 100; const isCloseButtonEnabled = showInstructions || progress >= 100
return ( return (
<div <div
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`} className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}> <div
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
>
<h3>{title}</h3> <h3>{title}</h3>
<p>{message}</p> <p>{message}</p>
<div className="progress-bar-container"> <div className="progress-bar-container">
<div <div className="progress-bar" style={{ width: `${progress}%` }} />
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div> </div>
<div className="progress-percentage">{Math.round(progress)}%</div> <div className="progress-percentage">{Math.round(progress)}%</div>
{showInstructions && instructions && ( {showInstructions && instructions && (
<div className="instruction-container"> <div className="instruction-container">
<h4> <h4>
{instructions.type.includes('uninstall') {instructions.type.includes('uninstall')
? 'Uninstallation Instructions' ? 'Uninstallation Instructions'
: 'Installation Instructions'} : 'Installation Instructions'}
</h4> </h4>
{getInstructionText()} {getInstructionText()}
<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"> <div className="action-buttons">
{showCopyButton && ( {showCopyButton && (
<button <button className="copy-button" onClick={handleCopyCommand}>
className="copy-button"
onClick={handleCopyCommand}
>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'} {copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</button> </button>
)} )}
<button <button
className="close-button" className="close-button"
onClick={handleClose} onClick={handleClose}
disabled={!isCloseButtonEnabled} disabled={!isCloseButtonEnabled}
@@ -195,21 +192,18 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
</div> </div>
</div> </div>
)} )}
{/* Show close button even if no instructions */} {/* Show close button even if no instructions */}
{!showInstructions && progress >= 100 && ( {!showInstructions && progress >= 100 && (
<div className="action-buttons" style={{ marginTop: '1rem' }}> <div className="action-buttons" style={{ marginTop: '1rem' }}>
<button <button className="close-button" onClick={handleClose}>
className="close-button"
onClick={handleClose}
>
Close Close
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
); )
}; }
export default ProgressDialog; export default ProgressDialog

View File

@@ -1,9 +1,8 @@
// src/components/Sidebar.tsx import React from 'react'
import React from 'react';
interface SidebarProps { interface SidebarProps {
setFilter: (filter: string) => void; setFilter: (filter: string) => void
currentFilter: string; currentFilter: string
} }
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => { const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
@@ -11,27 +10,24 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
<div className="sidebar"> <div className="sidebar">
<h2>Library</h2> <h2>Library</h2>
<ul className="filter-list"> <ul className="filter-list">
<li <li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
className={currentFilter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All Games All Games
</li> </li>
<li <li
className={currentFilter === 'native' ? 'active' : ''} className={currentFilter === 'native' ? 'active' : ''}
onClick={() => setFilter('native')} onClick={() => setFilter('native')}
> >
Native Native
</li> </li>
<li <li
className={currentFilter === 'proton' ? 'active' : ''} className={currentFilter === 'proton' ? 'active' : ''}
onClick={() => setFilter('proton')} onClick={() => setFilter('proton')}
> >
Proton Required Proton Required
</li> </li>
</ul> </ul>
</div> </div>
); )
}; }
export default Sidebar; export default Sidebar

View File

@@ -5,5 +5,5 @@ import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
) )

View File

@@ -1,5 +1,3 @@
// src/services/ImageService.ts
/** /**
* Game image sources from Steam's CDN * Game image sources from Steam's CDN
*/ */
@@ -9,88 +7,87 @@ export const SteamImageType = {
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
const imageCache: Map<string, string> = new Map(); const imageCache: Map<string, string> = new Map()
/** /**
* Builds a Steam CDN URL for game images * Builds a Steam CDN URL for game images
* @param appId Steam application ID * @param appId Steam application ID
* @param type Image type from SteamImageType enum * @param type Image type from SteamImageType enum
* @returns URL string for the image * @returns URL string for the image
*/ */
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => { export const getSteamImageUrl = (
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`; appId: string,
}; type: (typeof SteamImageType)[SteamImageTypeKey]
) => {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
}
/** /**
* Checks if an image exists by performing a HEAD request * Checks if an image exists by performing a HEAD request
* @param url Image URL to check * @param url Image URL to check
* @returns Promise resolving to a boolean indicating if the image exists * @returns Promise resolving to a boolean indicating if the image exists
*/ */
export const checkImageExists = async (url: string): Promise<boolean> => { export const checkImageExists = async (url: string): Promise<boolean> => {
try { try {
const response = await fetch(url, { method: 'HEAD' }); const response = await fetch(url, { method: 'HEAD' })
return response.ok; return response.ok
} catch (error) { } catch (error) {
console.error('Error checking image existence:', error); console.error('Error checking image existence:', error)
return false; return false
}
};
/**
* Preloads an image for faster rendering
* @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = reject;
img.src = url;
});
};
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null;
}
// Try these image types in order of preference
const typesToTry = [
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
];
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type);
const exists = await checkImageExists(url);
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url);
// Store in cache
imageCache.set(appId, preloadedUrl);
return preloadedUrl;
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url);
return url;
}
} }
} }
// If we've reached here, no valid image was found /**
return null; * Preloads an image for faster rendering
}; * @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(url)
img.onerror = reject
img.src = url
})
}
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null
}
// Try these image types in order of preference
const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type)
const exists = await checkImageExists(url)
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url)
// Store in cache
imageCache.set(appId, preloadedUrl)
return preloadedUrl
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url)
return url
}
}
}
// If we've reached here, no valid image was found
return null
}

View File

@@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('../assets/fonts/Satoshi.ttf') format('ttf'), src:
url('../assets/fonts/Roboto.ttf') format('ttf'), url('../assets/fonts/Satoshi.ttf') format('ttf'),
url('../assets/fonts/WorkSans.ttf') format('ttf'); url('../assets/fonts/Roboto.ttf') format('ttf'),
font-weight: 400; // adjust as needed url('../assets/fonts/WorkSans.ttf') format('ttf');
font-style: normal; font-weight: 400;
font-display: swap; font-style: normal;
} font-display: swap;
}

View File

@@ -1,5 +1,3 @@
// src/styles/_layout.scss
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;
@@ -23,7 +21,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image:
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%), radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%); radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
pointer-events: none; pointer-events: none;
@@ -41,7 +39,7 @@
position: relative; position: relative;
z-index: var(--z-header); z-index: var(--z-header);
height: var(--header-height); height: var(--header-height);
h1 { h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
@@ -57,7 +55,12 @@
left: 0; left: 0;
right: 0; right: 0;
height: 3px; height: 3px;
background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color)); background: linear-gradient(
90deg,
var(--cream-color),
var(--primary-color),
var(--smoke-color)
);
opacity: 0.7; opacity: 0.7;
} }
@@ -71,7 +74,7 @@
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
} }
} }
.header-controls { .header-controls {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -88,7 +91,7 @@
z-index: var(--z-elevate); z-index: var(--z-elevate);
} }
/* Sidebar */ // Sidebar
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
min-width: var(--sidebar-width); min-width: var(--sidebar-width);
@@ -161,7 +164,8 @@
} }
// Loading and empty state // Loading and empty state
.loading-indicator, .no-games-message { .loading-indicator,
.no-games-message {
@include flex-center; @include flex-center;
height: 250px; height: 250px;
width: 100%; width: 100%;
@@ -185,12 +189,7 @@
left: -100%; left: -100%;
width: 50%; width: 50%;
height: 100%; height: 100%;
background: linear-gradient( background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: loading-shimmer 2s infinite; animation: loading-shimmer 2s infinite;
} }
} }
@@ -259,4 +258,4 @@
to { to {
left: 100%; left: 100%;
} }
} }

View File

@@ -1,9 +1,5 @@
// src/styles/_mixins.scss
@use './variables' as *; @use './variables' as *;
// src/styles/_mixins.scss
// Basic flex helpers // Basic flex helpers
@mixin flex-center { @mixin flex-center {
display: flex; display: flex;
@@ -43,7 +39,7 @@
} }
@mixin shadow-hover { @mixin shadow-hover {
box-shadow: var(--shadow-hover);; box-shadow: var(--shadow-hover);
} }
@mixin text-shadow { @mixin text-shadow {
@@ -60,19 +56,27 @@
// Responsive mixins // Responsive mixins
@mixin media-sm { @mixin media-sm {
@media (min-width: 576px) { @content; } @media (min-width: 576px) {
@content;
}
} }
@mixin media-md { @mixin media-md {
@media (min-width: 768px) { @content; } @media (min-width: 768px) {
@content;
}
} }
@mixin media-lg { @mixin media-lg {
@media (min-width: 992px) { @content; } @media (min-width: 992px) {
@content;
}
} }
@mixin media-xl { @mixin media-xl {
@media (min-width: 1200px) { @content; } @media (min-width: 1200px) {
@content;
}
} }
// Card base styling // Card base styling
@@ -104,4 +108,4 @@
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, white 10%, var(--primary-color)); background: color-mix(in srgb, white 10%, var(--primary-color));
} }
} }

View File

@@ -1,9 +1,6 @@
// src/styles/_reset.scss
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;
@use './fonts' as *; @use './fonts' as *;
// src/styles/_reset.scss
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -11,7 +8,8 @@
padding: 0; padding: 0;
} }
html, body { html,
body {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -23,7 +21,7 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--primary-bg); background-color: var(--primary-bg);
color: var(--text-primary); color: var(--text-primary);
/* Prevent text selection by default */ // Prevent text selection by default
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@@ -51,15 +49,24 @@ a {
text-decoration: none; text-decoration: none;
} }
ul, ol { ul,
ol {
list-style: none; list-style: none;
} }
input, button, textarea, select { input,
button,
textarea,
select {
font: inherit; font: inherit;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: inherit; font-weight: inherit;
font-size: inherit; font-size: inherit;
} }

View File

@@ -1,111 +1,102 @@
// src/styles/_variables.scss
@use './fonts' as *; @use './fonts' as *;
// Color palette // Color palette
:root { :root {
// Primary colors // Primary colors
--primary-color: #ffc896; --primary-color: #ffc896;
--secondary-color: #ffb278; --secondary-color: #ffb278;
// Background // Background
--primary-bg: #0f0f0f; --primary-bg: #0f0f0f;
--secondary-bg: #151515; --secondary-bg: #151515;
--tertiary-bg: #121212; --tertiary-bg: #121212;
--elevated-bg: #1a1a1a; --elevated-bg: #1a1a1a;
--disabled: #5E5E5E; --disabled: #5e5e5e;
// Text // Text
--text-primary: #f0f0f0; --text-primary: #f0f0f0;
--text-secondary: #c8c8c8; --text-secondary: #c8c8c8;
--text-soft: #afafaf; --text-soft: #afafaf;
--text-heavy: #1a1a1a; --text-heavy: #1a1a1a;
--text-muted: #4b4b4b; --text-muted: #4b4b4b;
// Borders // Borders
--border-dark: #1a1a1a; --border-dark: #1a1a1a;
--border-soft: #282828; --border-soft: #282828;
--border: #323232; --border: #323232;
// Status colors - more vibrant
--success: #8cc893;
--warning: #ffc896;
--danger: #d96b6b;
--info: #80b4ff;
--success-light: #b0e0a9; // Status colors
--warning-light: #ffdcb9; --success: #8cc893;
--danger-light: #e69691; --warning: #ffc896;
--info-light: #a8d2ff; --danger: #d96b6b;
--info: #80b4ff;
--success-soft: rgba(176, 224, 169, 0.15); --success-light: #b0e0a9;
--warning-soft: rgba(247, 200, 111, 0.15); --warning-light: #ffdcb9;
--danger-soft: rgba(230, 150, 145, 0.15); --danger-light: #e69691;
--info-soft: rgba(168, 210, 255, 0.15); --info-light: #a8d2ff;
// Feature colors --success-soft: rgba(176, 224, 169, 0.15);
--native: #8cc893; --warning-soft: rgba(247, 200, 111, 0.15);
--proton: #ffc896; --danger-soft: rgba(230, 150, 145, 0.15);
--cream: #80b4ff; --info-soft: rgba(168, 210, 255, 0.15);
--smoke: #fff096;
--modal-backdrop: rgba(30, 30, 30, 0.95);
// Animation durations
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
// Animation easings
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
// Layout values // Feature colors
--header-height: 64px; --native: #8cc893;
--sidebar-width: 250px; --proton: #ffc896;
--card-height: 200px; --cream: #80b4ff;
--smoke: #fff096;
// Border radius --modal-backdrop: rgba(30, 30, 30, 0.95);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
// Font weights // Animation durations
--thin: 100; --duration-fast: 100ms;
--extralight: 200; --duration-normal: 200ms;
--light: 300; --duration-slow: 300ms;
--normal: 400;
--medium: 500;
--semibold: 600;
--bold: 700;
--extrabold: 800;
--family: 'Satoshi'; // Animation easings
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
// Shadows // Layout values
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --header-height: 64px;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3); --sidebar-width: 250px;
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3); --card-height: 200px;
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
// Z-index levels // Border radius
//--z-index-bg: 0; --radius-sm: 6px;
//--z-index-content: 1; --radius-md: 8px;
//--z-index-header: 100; --radius-lg: 12px;
//--z-index-modal: 1000;
//--z-index-tooltip: 1500;
// Z-index levels // Font weights
--z-bg: 0; --thin: 100;
--z-elevate: 1; --extralight: 200;
--z-header: 100; --light: 300;
--z-modal: 1000; --normal: 400;
--z-tooltip: 1500; --medium: 500;
--semibold: 600;
--bold: 700;
--extrabold: 800;
--family: 'Satoshi';
// Shadows
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
// Z-index levels
--z-bg: 0;
--z-elevate: 1;
--z-header: 100;
--z-modal: 1000;
--z-tooltip: 1500;
} }
$success-color: #55e07a; $success-color: #55e07a;
@@ -113,4 +104,4 @@ $danger-color: #ff5252;
$primary-color: #4a76c4; $primary-color: #4a76c4;
$cream-color: #9b7dff; $cream-color: #9b7dff;
$smoke-color: #fbb13c; $smoke-color: #fbb13c;
$warning-color: #fbb13c; $warning-color: #fbb13c;

View File

@@ -1,5 +1,3 @@
// src/styles/components/_animated_checkbox.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -9,7 +7,7 @@
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
position: relative; position: relative;
&:hover .checkbox-custom { &:hover .checkbox-custom {
border-color: rgba(255, 255, 255, 0.3); border-color: rgba(255, 255, 255, 0.3);
} }
@@ -35,7 +33,7 @@
margin-right: 15px; margin-right: 15px;
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
&.checked { &.checked {
background-color: var(--primary-color, #ffc896); background-color: var(--primary-color, #ffc896);
border-color: var(--primary-color, #ffc896); border-color: var(--primary-color, #ffc896);
@@ -53,7 +51,7 @@
stroke-dashoffset: 30; stroke-dashoffset: 30;
opacity: 0; opacity: 0;
transition: stroke-dashoffset 0.3s ease; transition: stroke-dashoffset 0.3s ease;
&.checked { &.checked {
stroke-dashoffset: 0; stroke-dashoffset: 0;
opacity: 1; opacity: 1;
@@ -95,4 +93,4 @@
stroke-dashoffset: 0; stroke-dashoffset: 0;
opacity: 1; opacity: 1;
} }
} }

View File

@@ -1,5 +1,3 @@
// src/styles/_components/_background.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@use 'sass:color'; @use 'sass:color';
@@ -13,4 +11,4 @@
pointer-events: none; pointer-events: none;
z-index: var(--z-bg); z-index: var(--z-bg);
opacity: 0.4; opacity: 0.4;
} }

View File

@@ -1,9 +1,7 @@
// src/styles/_components/_dialog.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
/* Progress Dialog */ // Progress Dialog
.progress-dialog-overlay { .progress-dialog-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -17,22 +15,28 @@
opacity: 0; opacity: 0;
animation: modal-appear 0.2s ease-out; animation: modal-appear 0.2s ease-out;
cursor: pointer; cursor: pointer;
&.visible { &.visible {
opacity: 1; opacity: 1;
} }
@keyframes modal-appear { @keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); } 0% {
100% { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
} }
.progress-dialog { .progress-dialog {
background-color: var(--elevated-bg); background-color: var(--elevated-bg);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */ box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); // shadow-glow
width: 450px; width: 450px;
max-width: 90vw; max-width: 90vw;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
@@ -43,17 +47,17 @@
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
&.with-instructions { &.with-instructions {
width: 500px; width: 500px;
} }
h3 { h3 {
font-weight: 700; font-weight: 700;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary); color: var(--text-primary);
} }
p { p {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -85,7 +89,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Instruction container in progress dialog */ // Instruction container in progress dialog
.instruction-container { .instruction-container {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem; padding-top: 1rem;
@@ -112,7 +116,7 @@
color: var(--info); color: var(--info);
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; font-size: 0.8rem;
&::before { &::before {
content: ''; content: '';
display: inline-block; display: inline-block;
@@ -143,7 +147,7 @@
max-width: 100%; max-width: 100%;
} }
} }
.selectable-text { .selectable-text {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
@@ -164,7 +168,8 @@
justify-content: flex-end; justify-content: flex-end;
} }
.copy-button, .close-button { .copy-button,
.close-button {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 600;
@@ -180,7 +185,7 @@
&:hover { &:hover {
background-color: var(--primary-color); background-color: var(--primary-color);
transform: translateY(-2px) scale(1.02); /* hover-lift */ transform: translateY(-2px) scale(1.02); // hover-lift
box-shadow: 0 6px 14px var(--info-soft); box-shadow: 0 6px 14px var(--info-soft);
} }
} }
@@ -191,7 +196,7 @@
&:hover { &:hover {
background-color: var(--border); background-color: var(--border);
transform: translateY(-2px) scale(1.02); /* hover-lift */ transform: translateY(-2px) scale(1.02); // hover-lift
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3); box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
} }
} }
@@ -210,20 +215,20 @@
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
text-align: center; text-align: center;
h3 { h3 {
color: var(--danger); color: var(--danger);
font-weight: 700; font-weight: 700;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
p { p {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
button { button {
background-color: var(--primary-color); background-color: var(--primary-color);
color: var(--text-primary); color: var(--text-primary);
@@ -234,7 +239,7 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
@include transition-standard; @include transition-standard;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4); box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
@@ -244,6 +249,10 @@
// Animation for progress bar // Animation for progress bar
@keyframes progress-shimmer { @keyframes progress-shimmer {
0% { transform: translateX(-100%); } 0% {
100% { transform: translateX(100%); } transform: translateX(-100%);
} }
100% {
transform: translateX(100%);
}
}

View File

@@ -1,5 +1,3 @@
// src/styles/components/_dlc_dialog.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -15,7 +13,7 @@
z-index: var(--z-modal); z-index: var(--z-modal);
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
&.visible { &.visible {
opacity: 1; opacity: 1;
animation: modal-appear 0.2s ease-out; animation: modal-appear 0.2s ease-out;
@@ -35,18 +33,20 @@
cursor: default; cursor: default;
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
&.dialog-visible { &.dialog-visible {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out; transition:
transform 0.2s var(--easing-bounce),
opacity 0.2s ease-out;
} }
} }
.dlc-dialog-header { .dlc-dialog-header {
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
h3 { h3 {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
@@ -60,12 +60,12 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 0.5rem; margin-top: 0.5rem;
.game-title { .game-title {
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
} }
.dlc-count { .dlc-count {
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
@@ -94,13 +94,13 @@
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
@include transition-standard; @include transition-standard;
&:focus { &:focus {
border-color: var(--primary-color); border-color: var(--primary-color);
outline: none; outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
} }
&::placeholder { &::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -110,12 +110,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 100px; min-width: 100px;
// Custom styling for the select all checkbox // Custom styling for the select all checkbox
:global(.animated-checkbox) { :global(.animated-checkbox) {
margin-left: auto; margin-left: auto;
} }
:global(.checkbox-label) { :global(.checkbox-label) {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -126,7 +126,7 @@
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
.progress-bar-container { .progress-bar-container {
height: 6px; height: 6px;
background-color: var(--border-soft); background-color: var(--border-soft);
@@ -134,7 +134,7 @@
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background-color: var(--primary-color); background-color: var(--primary-color);
@@ -143,13 +143,13 @@
background: var(--primary-color); background: var(--primary-color);
box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3); box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3);
} }
.loading-details { .loading-details {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
.time-left { .time-left {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -171,54 +171,56 @@
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
@include transition-standard; @include transition-standard;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.03); background-color: rgba(255, 255, 255, 0.03);
} }
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
&.dlc-item-loading { &.dlc-item-loading {
height: 30px; height: 30px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
.loading-pulse { .loading-pulse {
width: 70%; width: 70%;
height: 20px; height: 20px;
background: linear-gradient(90deg, background: linear-gradient(
var(--border-soft) 0%, 90deg,
var(--border) 50%, var(--border-soft) 0%,
var(--border-soft) 100%); var(--border) 50%,
var(--border-soft) 100%
);
background-size: 200% 100%; background-size: 200% 100%;
border-radius: 4px; border-radius: 4px;
animation: loading-pulse 1.5s infinite; animation: loading-pulse 1.5s infinite;
} }
} }
// Enhanced styling for the checkbox component inside dlc-item // Styling for the checkbox component inside dlc-item
:global(.animated-checkbox) { :global(.animated-checkbox) {
width: 100%; width: 100%;
.checkbox-label { .checkbox-label {
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 500;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.checkbox-sublabel { .checkbox-sublabel {
color: var(--text-muted); color: var(--text-muted);
} }
// Optional hover effect // Optional hover effect
&:hover { &:hover {
.checkbox-label { .checkbox-label {
color: var(--primary-color); color: var(--primary-color);
} }
.checkbox-custom { .checkbox-custom {
border-color: var(--primary-color, #ffc896); border-color: var(--primary-color, #ffc896);
transform: scale(1.05); transform: scale(1.05);
@@ -232,7 +234,7 @@
@include flex-center; @include flex-center;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
.loading-spinner { .loading-spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -241,7 +243,7 @@
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
p { p {
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -261,7 +263,8 @@
gap: 1rem; gap: 1rem;
} }
.cancel-button, .confirm-button { .cancel-button,
.confirm-button {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 600;
@@ -274,7 +277,7 @@
.cancel-button { .cancel-button {
background-color: var(--border-soft); background-color: var(--border-soft);
color: var(--text-primary); color: var(--text-primary);
&:hover { &:hover {
background-color: var(--border); background-color: var(--border);
transform: translateY(-2px); transform: translateY(-2px);
@@ -285,12 +288,12 @@
.confirm-button { .confirm-button {
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: white;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 14px var(--info-soft); box-shadow: 0 6px 14px var(--info-soft);
} }
&:disabled { &:disabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed; cursor: not-allowed;
@@ -299,16 +302,30 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
@keyframes modal-appear { @keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); } 0% {
100% { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
@keyframes loading-pulse { @keyframes loading-pulse {
0% { background-position: 200% 50%; } 0% {
100% { background-position: 0% 50%; } background-position: 200% 50%;
} }
100% {
background-position: 0% 50%;
}
}

View File

@@ -1,5 +1,3 @@
// src/styles/components/_gamecard.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -12,7 +10,7 @@
@include shadow-standard; @include shadow-standard;
@include transition-standard; @include transition-standard;
transform-origin: center; transform-origin: center;
// Simple image loading animation // Simple image loading animation
opacity: 0; opacity: 0;
animation: fadeIn 0.5s forwards; animation: fadeIn 0.5s forwards;
@@ -23,19 +21,19 @@
transform: translateY(-8px) scale(1.02); transform: translateY(-8px) scale(1.02);
@include shadow-hover; @include shadow-hover;
z-index: 5; z-index: 5;
.status-badge.native { .status-badge.native {
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5) box-shadow: 0 0 10px rgba(85, 224, 122, 0.5);
} }
.status-badge.proton { .status-badge.proton {
box-shadow: 0 0 10px rgba(255, 201, 150, 0.5); box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
} }
.status-badge.cream { .status-badge.cream {
box-shadow: 0 0 10px rgba(128, 181, 255, 0.5); box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
} }
.status-badge.smoke { .status-badge.smoke {
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5); box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
} }
@@ -43,11 +41,15 @@
// Special styling for cards with different statuses // Special styling for cards with different statuses
.game-item-card:has(.status-badge.cream) { .game-item-card:has(.status-badge.cream) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15); box-shadow:
var(--shadow-standard),
0 0 15px rgba(128, 181, 255, 0.15);
} }
.game-item-card:has(.status-badge.smoke) { .game-item-card:has(.status-badge.smoke) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15); box-shadow:
var(--shadow-standard),
0 0 15px rgba(255, 239, 150, 0.15);
} }
// Simple clean overlay // Simple clean overlay
@@ -57,7 +59,8 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(to bottom, background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.5) 0%,
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%
@@ -70,7 +73,7 @@
font-family: var(--family); font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
color: var(--text-heavy);; color: var(--text-heavy);
z-index: 1; z-index: 1;
} }
@@ -92,7 +95,7 @@
font-family: var(--family); font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
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; @include transition-standard;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -129,7 +132,7 @@
margin: 0; margin: 0;
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
transform: translateZ(0); // or transform: translateZ(0);
will-change: opacity, transform; will-change: opacity, transform;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
overflow: hidden; overflow: hidden;
@@ -176,7 +179,7 @@
.action-button.uninstall:hover { .action-button.uninstall:hover {
background-color: var(--danger-light); background-color: var(--danger-light);
transform: translateY(-2px) scale(1.02); transform: translateY(-2px) scale(1.02);
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3) box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
} }
.action-button:active { .action-button:active {
@@ -241,11 +244,11 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
span { span {
flex: 1; flex: 1;
} }
.rescan-button { .rescan-button {
background-color: var(--warning); background-color: var(--warning);
color: var(--text-heavy); color: var(--text-heavy);
@@ -257,12 +260,12 @@
margin-left: 0.5rem; margin-left: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover { &:hover {
background-color: var(--warning-light); background-color: var(--warning-light);
transform: translateY(-2px); transform: translateY(-2px);
} }
&:active { &:active {
transform: translateY(0); transform: translateY(0);
} }
@@ -271,17 +274,23 @@
// Apply staggered delay to cards // Apply staggered delay to cards
@for $i from 1 through 12 { @for $i from 1 through 12 {
.game-grid .game-item-card:nth-child(#{$i}) { .game-grid .game-item-card:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s; animation-delay: #{$i * 0.05}s;
} }
} }
// Simple animations // Simple animations
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes button-loading { @keyframes button-loading {
to { left: 100%; } to {
} left: 100%;
}
}

View File

@@ -1,82 +1,80 @@
// src/styles/_components/_header.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
.app-container { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--primary-bg); background-color: var(--primary-bg);
position: relative; position: relative;
} }
// Header // Header
.app-header { .app-header {
@include flex-between; @include flex-between;
padding: 1rem 2rem; padding: 1rem 2rem;
background-color: var(--tertiary-bg); background-color: var(--tertiary-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.07); border-bottom: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
position: relative; position: relative;
z-index: var(--z-header); z-index: var(--z-header);
height: var(--header-height); height: var(--header-height);
h1 { h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; 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); color: var(--text-primary);
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
font-weight: var(--bold);
letter-spacing: 0.5px; letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); @include text-shadow;
transition: all 0.2s ease;
} }
}
.refresh-button:hover {
transform: translateY(-2px); .header-controls {
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3); display: flex;
background-color: var(--primary-color); gap: 1rem;
} align-items: center;
}
.refresh-button:active {
transform: translateY(0); .refresh-button {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); background-color: var(--primary-color);
} color: var(--text-primary);
border: none;
.search-input { border-radius: 4px;
padding: 0.5rem 1rem; padding: 0.6rem 1.2rem;
border: 1px solid var(--border-soft); font-weight: var(--bold);
border-radius: 4px; letter-spacing: 0.5px;
min-width: 200px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
background-color: var(--border-dark); transition: all 0.2s ease;
color: var(--text-primary); }
}
.refresh-button:hover {
.search-input:focus { transform: translateY(-2px);
border-color: var(--primary-color); box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
outline: none; background-color: var(--primary-color);
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); }
}
.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);
}

View File

@@ -9,13 +9,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: var(--z-modal) + 1; z-index: var(--z-modal) + 1;
.loading-content { .loading-content {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -23,46 +23,46 @@
color: var(--primary-color); color: var(--primary-color);
text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4); text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
} }
.loading-animation { .loading-animation {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.loading-circles { .loading-circles {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
.circle { .circle {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both; animation: bounce 1.4s infinite ease-in-out both;
&.circle-1 { &.circle-1 {
background-color: var(--primary-color); background-color: var(--primary-color);
animation-delay: -0.32s; animation-delay: -0.32s;
} }
&.circle-2 { &.circle-2 {
background-color: var(--cream-color); background-color: var(--cream-color);
animation-delay: -0.16s; animation-delay: -0.16s;
} }
&.circle-3 { &.circle-3 {
background-color: var(--smoke-color); background-color: var(--smoke-color);
} }
} }
} }
.loading-message { .loading-message {
font-size: 1.1rem; font-size: 1.1rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 3rem; min-height: 3rem;
} }
.progress-bar-container { .progress-bar-container {
height: 8px; height: 8px;
background-color: var(--border-soft); background-color: var(--border-soft);
@@ -70,16 +70,21 @@
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background-color: var(--primary-color); background-color: var(--primary-color);
border-radius: 4px; border-radius: 4px;
transition: width 0.5s ease; transition: width 0.5s ease;
background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color)); background: linear-gradient(
to right,
var(--cream-color),
var(--primary-color),
var(--smoke-color)
);
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4); box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
} }
.progress-percentage { .progress-percentage {
text-align: right; text-align: right;
font-size: 0.875rem; font-size: 0.875rem;
@@ -91,10 +96,12 @@
// Animation for the bouncing circles // Animation for the bouncing circles
@keyframes bounce { @keyframes bounce {
0%, 80%, 100% { 0%,
80%,
100% {
transform: scale(0); transform: scale(0);
} }
40% { 40% {
transform: scale(1.0); transform: scale(1);
} }
} }

View File

@@ -1,12 +1,10 @@
// src/styles/_components/_sidebar.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
.filter-list { .filter-list {
list-style: none; list-style: none;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
li { li {
@include transition-standard; @include transition-standard;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -14,11 +12,11 @@
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.07); background-color: rgba(255, 255, 255, 0.07);
} }
&.active { &.active {
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color))); @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); box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
@@ -30,7 +28,7 @@
.custom-select { .custom-select {
position: relative; position: relative;
display: inline-block; display: inline-block;
.select-selected { .select-selected {
background-color: rgba(255, 255, 255, 0.07); background-color: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -45,18 +43,18 @@
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
min-width: 150px; min-width: 150px;
&:after { &:after {
content: ''; content: '';
font-size: 0.7rem; font-size: 0.7rem;
opacity: 0.7; opacity: 0.7;
} }
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
} }
.select-items { .select-items {
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -71,20 +69,20 @@
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.3s ease; transition: max-height 0.3s ease;
&.show { &.show {
max-height: 300px; max-height: 300px;
} }
.select-item { .select-item {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
cursor: pointer; cursor: pointer;
@include transition-standard; @include transition-standard;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.07); background-color: rgba(255, 255, 255, 0.07);
} }
&.selected { &.selected {
background-color: var(--primary-color); background-color: var(--primary-color);
color: var(--text-primary); color: var(--text-primary);
@@ -98,7 +96,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
svg { svg {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -111,13 +109,13 @@
.tooltip { .tooltip {
position: relative; position: relative;
display: inline-block; display: inline-block;
&:hover .tooltip-content { &:hover .tooltip-content {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
.tooltip-content { .tooltip-content {
visibility: hidden; visibility: hidden;
width: 200px; width: 200px;
@@ -133,14 +131,16 @@
margin-left: -100px; margin-left: -100px;
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
transition: opacity 0.3s, transform 0.3s; transition:
opacity 0.3s,
transform 0.3s;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.8rem; font-size: 0.8rem;
pointer-events: none; pointer-events: none;
&::after { &::after {
content: ""; content: '';
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
@@ -192,15 +192,17 @@
@include transition-standard; @include transition-standard;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
min-width: 200px; min-width: 200px;
&:focus { &:focus {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow:
0 0 0 2px rgba(var(--primary-color), 0.3),
inset 0 2px 5px rgba(0, 0, 0, 0.2);
} }
&::placeholder { &::placeholder {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
} }

View File

@@ -1,5 +1,3 @@
// src/styles/main.scss
// Import variables and mixins first // Import variables and mixins first
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;
@@ -18,4 +16,4 @@
@use './components/sidebar'; @use './components/sidebar';
@use './components/dlc_dialog'; @use './components/dlc_dialog';
@use './components/loading_screen'; @use './components/loading_screen';
@use './components/animated_checkbox'; @use './components/animated_checkbox';

View File

@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }

View File

@@ -1,12 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react'
// Removed unused import: loadEnv
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Vite options tailored for Tauri development
clearScreen: false, clearScreen: false,
server: { server: {
port: 1420, port: 1420,
@@ -14,11 +12,8 @@ export default defineConfig({
}, },
envPrefix: ['VITE_', 'TAURI_'], envPrefix: ['VITE_', 'TAURI_'],
build: { build: {
// Tauri supports es2021
target: ['es2021', 'chrome105', 'safari13'], target: ['es2021', 'chrome105', 'safari13'],
// Don't minify for debug builds
minify: 'esbuild', minify: 'esbuild',
// Produce sourcemaps for debug builds
sourcemap: true, sourcemap: true,
}, },
}); })