mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-05-02 04:52:03 -04:00
Compare commits
20 Commits
v1.4.2
...
220763b389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
220763b389 | ||
|
|
3d894266a7 | ||
|
|
33492a6a55 | ||
|
|
92f4d82e6c | ||
|
|
5896733fd4 | ||
|
|
1d72f24afa | ||
|
|
aea8a84335 | ||
|
|
a476819312 | ||
|
|
1bb62877a3 | ||
|
|
f8ea256637 | ||
|
|
0480d523e3 | ||
|
|
1571e9d87d | ||
|
|
f949ecf2f3 | ||
|
|
ecee6529ff | ||
|
|
d9819ef115 | ||
|
|
ff53cc7a46 | ||
|
|
1a1c7dfb3d | ||
|
|
769213288e | ||
|
|
85d670931a | ||
|
|
487e974274 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,7 +12,6 @@ dist
|
||||
dist-ssr
|
||||
docs
|
||||
*.local
|
||||
*.lock
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,3 +1,15 @@
|
||||
## [1.5.0] - 28-03-2026
|
||||
|
||||
### Added
|
||||
- Anonymous reporting system. Vote on whether CreamLinux or SmokeAPI works for a game
|
||||
- Opt-in dialog on first launch explaining what is collected and why
|
||||
- Rating button on game cards (only visible when opted in and an unlocker is installed)
|
||||
- Community vote display in the unlocker selection dialog and before installing SmokeAPI on Proton games
|
||||
- Votes track per-unlocker so CreamLinux and SmokeAPI ratings are independent
|
||||
- Previously submitted votes are stored locally so already-cast buttons are disabled on re-open
|
||||
- Config now automatically migrates missing fields on update without overwriting existing values
|
||||
- API source available at https://github.com/Novattz/Lactose/
|
||||
|
||||
## [1.4.2] - 13-03-2026
|
||||
|
||||
### Added
|
||||
|
||||
66
README.md
66
README.md
@@ -4,7 +4,7 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
|
||||
|
||||
## Watch the demo here:
|
||||
|
||||
[](https://www.youtube.com/watch?v=ZunhZnKFLlg)
|
||||
[](https://www.youtube.com/watch?v=neUDotrqnDM)
|
||||
|
||||
## Beta Status
|
||||
|
||||
@@ -46,6 +46,70 @@ While the core functionality is working, please be aware that this is an early r
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
|
||||
```
|
||||
|
||||
### Nix
|
||||
You can fetch this repository in your configuration using `pkgs.fetchFromGithub`:
|
||||
```nix
|
||||
let
|
||||
creamlinux = pkgs.callPackage (pkgs.fetchFromGitHub {
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
rev = "main";
|
||||
hash = ""; # You can use nix-prefetch-url to determine which value to put here, or paste the value returned by the error your rebuild will output
|
||||
}) {};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
or, using `builtins.fetchTarball`:
|
||||
```nix
|
||||
let
|
||||
creamlinux-src = builtins.fetchTarball {
|
||||
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
|
||||
sha256 = ""; # See above
|
||||
};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage creamlinux-src {})
|
||||
];
|
||||
}
|
||||
```
|
||||
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
|
||||
```bash
|
||||
npins add github Novattz creamlinux-installer --branch main
|
||||
```
|
||||
```nix
|
||||
let
|
||||
sources = import ./npins;
|
||||
creamlinux = pkgs.callPackage sources.creamlinux-installer {};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
Those are the recommended methods to add creamlinux-installer to your environment. However, you could also add it as an input of your flake, like so:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
creamlinux-installer = {
|
||||
type = "github";
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
Then, in your configuration:
|
||||
```nix
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage inputs.creamlinux-installer {})
|
||||
];
|
||||
```
|
||||
|
||||
### Building from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
57
default.nix
Normal file
57
default.nix
Normal file
@@ -0,0 +1,57 @@
|
||||
{pkgs ? import <nixpkgs> {}}: let
|
||||
cargoRoot = "src-tauri";
|
||||
src = ./.;
|
||||
|
||||
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
|
||||
NIX_LD="${pkgs.lib.fileContents "${pkgs.stdenv.cc}/nix-support/dynamic-linker"}"
|
||||
for dart_bin in node_modules/sass-embedded-linux-*/dart-sass/src/dart; do
|
||||
if [ -f "$dart_bin" ]; then
|
||||
${pkgs.patchelf}/bin/patchelf --set-interpreter "$NIX_LD" "$dart_bin"
|
||||
fi
|
||||
done
|
||||
'';
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "creamlinux-installer";
|
||||
version = "1.5.0-unstable-2026-04-23";
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./src-tauri/Cargo.lock;
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-anYTERlnfOGDsGW0joy+h7wECJNDy6q+0a2to6t36pg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs =
|
||||
[
|
||||
pkgs.cargo-tauri.hook
|
||||
pkgs.nodejs
|
||||
pkgs.npmHooks.npmConfigHook
|
||||
pkgs.pkg-config
|
||||
]
|
||||
++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.glib-networking
|
||||
pkgs.openssl
|
||||
pkgs.webkitgtk_4_1
|
||||
];
|
||||
|
||||
inherit cargoRoot;
|
||||
|
||||
buildAndTestSubdir = cargoRoot;
|
||||
|
||||
postPatch = ''
|
||||
substituteInPlace src-tauri/tauri.conf.json \
|
||||
--replace-fail '"createUpdaterArtifacts": true' '"createUpdaterArtifacts": false'
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
${patchSassEmbedded}/bin/patch-sass-embedded
|
||||
'';
|
||||
|
||||
env.NO_STRIP = true;
|
||||
}
|
||||
4685
package-lock.json
generated
4685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"private": true,
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"author": "Tickbase",
|
||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
||||
|
||||
6607
src-tauri/Cargo.lock
generated
Normal file
6607
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "creamlinux-installer"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["tickbase"]
|
||||
license = "MIT"
|
||||
@@ -30,11 +30,13 @@ tauri-plugin-shell = "2.0.0-rc"
|
||||
tauri-plugin-dialog = "2.0.0-rc"
|
||||
tauri-plugin-fs = "2.0.0-rc"
|
||||
num_cpus = "1.16.0"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-process = "2.2.1"
|
||||
async-trait = "0.1.89"
|
||||
sha2 = "0.10.9"
|
||||
rand = "0.9.2"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-updater = "2.7.1"
|
||||
|
||||
2
src-tauri/src/cache/mod.rs
vendored
2
src-tauri/src/cache/mod.rs
vendored
@@ -5,7 +5,7 @@ pub use storage::{
|
||||
get_creamlinux_version_dir, get_smokeapi_version_dir,
|
||||
list_creamlinux_files, list_smokeapi_files, read_versions,
|
||||
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
|
||||
validate_creamlinux_cache,
|
||||
validate_creamlinux_cache, get_cache_dir,
|
||||
};
|
||||
|
||||
pub use version::{
|
||||
|
||||
@@ -8,12 +8,17 @@ use log::info;
|
||||
pub struct Config {
|
||||
// Whether to show the disclaimer on startup
|
||||
pub show_disclaimer: bool,
|
||||
// Reporting / compatibility voting
|
||||
pub reporting_opted_in: bool,
|
||||
pub reporting_has_seen_prompt: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_disclaimer: true,
|
||||
reporting_opted_in: false,
|
||||
reporting_has_seen_prompt: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,10 +69,49 @@ pub fn load_config() -> Result<Config, String> {
|
||||
let config_str = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read config file: {}", e))?;
|
||||
|
||||
let config: Config = serde_json::from_str(&config_str)
|
||||
let mut on_disk: serde_json::Value = serde_json::from_str(&config_str)
|
||||
.map_err(|e| format!("Failed to parse config file: {}", e))?;
|
||||
|
||||
// Serialize the defaults into a Value so we can iterate their keys
|
||||
let defaults = serde_json::to_value(Config::default())
|
||||
.map_err(|e| format!("Failed to serialize default config: {}", e))?;
|
||||
|
||||
// For every key that exists in the current Config but is absent from the
|
||||
// on-disk JSON, inject the default value. Keys that are already present
|
||||
// are left completely untouched.
|
||||
let mut migrated = false;
|
||||
if let Some(default_obj) = defaults.as_object() {
|
||||
let missing: Vec<(String, serde_json::Value)> = default_obj
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
on_disk
|
||||
.as_object()
|
||||
.map_or(false, |d| !d.contains_key(*key))
|
||||
})
|
||||
.map(|(key, val)| (key.clone(), val.clone()))
|
||||
.collect();
|
||||
|
||||
if let Some(disk_obj) = on_disk.as_object_mut() {
|
||||
for (key, value) in missing {
|
||||
info!("Config migration: adding missing field '{}' with default value", key);
|
||||
disk_obj.insert(key, value);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize the (possiblyh augmented) value into Config
|
||||
let config: Config = serde_json::from_value(on_disk)
|
||||
.map_err(|e| format!("Failed to deserialize config: {}", e))?;
|
||||
|
||||
// Persist the migrated file so the next launch doesn't need to do this again
|
||||
if migrated {
|
||||
save_config(&config)?;
|
||||
info!("Config migrated - new fields written to disk");
|
||||
} else {
|
||||
info!("Loaded config from {:?}", config_path);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
)]
|
||||
|
||||
mod cache;
|
||||
mod reporting;
|
||||
mod utils;
|
||||
mod dlc_manager;
|
||||
mod installer;
|
||||
@@ -613,6 +614,81 @@ async fn resolve_platform_conflict(
|
||||
Ok(updated_game)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_reporting_opt_in(opted_in: bool) -> Result<(), String> {
|
||||
config::update_config(|cfg| {
|
||||
cfg.reporting_opted_in = opted_in;
|
||||
cfg.reporting_has_seen_prompt = true;
|
||||
})?;
|
||||
|
||||
if opted_in {
|
||||
// Ensure a salt exists so future hashes work immediately
|
||||
reporting::delete_salt().ok(); // clear any stale one first
|
||||
// re-create via generate_user_hash is fine; salt is lazy-created there
|
||||
} else {
|
||||
reporting::delete_salt()?;
|
||||
}
|
||||
|
||||
info!("Reporting opt-in set to: {}", opted_in);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn submit_report(
|
||||
game_id: String,
|
||||
unlocker: String,
|
||||
worked: bool,
|
||||
steam_path: String,
|
||||
) -> Result<(), String> {
|
||||
let user_hash = reporting::generate_user_hash(&steam_path)?;
|
||||
|
||||
reporting::post_report(reporting::ReportPayload {
|
||||
user_hash,
|
||||
game_id: game_id.clone(),
|
||||
unlocker: unlocker.clone(),
|
||||
worked,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Always save locally so the UI can reflect the vote immediately,
|
||||
// regardless of opt-in status (the local file is only used client-side).
|
||||
reporting::save_local_report(reporting::LocalReport {
|
||||
game_id,
|
||||
unlocker,
|
||||
worked,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_local_reports() -> Vec<reporting::LocalReport> {
|
||||
reporting::load_local_reports()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_game_votes(game_id: String) -> Result<Vec<reporting::VoteResult>, String> {
|
||||
let url = format!("https://api.shibe.fun/v1/votes/{}", game_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch votes: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Non-critical - return empty rather than surfacing an error to the UI
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<reporting::VoteResult>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse votes: {}", e))
|
||||
}
|
||||
|
||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
@@ -676,6 +752,10 @@ fn main() {
|
||||
resolve_platform_conflict,
|
||||
load_config,
|
||||
update_config,
|
||||
set_reporting_opt_in,
|
||||
submit_report,
|
||||
get_local_reports,
|
||||
get_game_votes,
|
||||
])
|
||||
.setup(|app| {
|
||||
info!("Tauri application setup");
|
||||
|
||||
177
src-tauri/src/reporting.rs
Normal file
177
src-tauri/src/reporting.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use crate::cache::get_cache_dir;
|
||||
use crate::config;
|
||||
use log::{info, warn};
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
const API_BASE: &str = "https://api.shibe.fun/v1";
|
||||
const SALT_LENGTH: usize = 32;
|
||||
|
||||
// Report payload
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ReportPayload {
|
||||
pub user_hash: String,
|
||||
pub game_id: String,
|
||||
/// "creamlinux" | "smokeapi"
|
||||
pub unlocker: String,
|
||||
/// true = worked, false = didn't work
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
/// Mirrors the JSON returned by GET /v1/votes/:game_id
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VoteResult {
|
||||
pub unlocker: String,
|
||||
pub success: u32,
|
||||
pub fail: u32,
|
||||
}
|
||||
|
||||
// Local report record
|
||||
|
||||
/// One entry in the local reports.json cache.
|
||||
/// Tracks what the user has already voted so we can disable buttons in the UI.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LocalReport {
|
||||
pub game_id: String,
|
||||
pub unlocker: String, // "creamlinux" | "smokeapi"
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
// reports.json helpers
|
||||
|
||||
fn reports_cache_path() -> Result<std::path::PathBuf, String> {
|
||||
Ok(get_cache_dir()?.join("reports.json"))
|
||||
}
|
||||
|
||||
/// Load all locally recorded votes.
|
||||
pub fn load_local_reports() -> Vec<LocalReport> {
|
||||
match reports_cache_path() {
|
||||
Ok(path) if path.exists() => {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a new vote to reports.json (or overwrite an existing one for the same
|
||||
/// game_id + unlocker combo).
|
||||
pub fn save_local_report(report: LocalReport) -> Result<(), String> {
|
||||
let path = reports_cache_path()?;
|
||||
let mut reports = load_local_reports();
|
||||
|
||||
// Upsert: replace existing entry for the same game + unlocker, otherwise push
|
||||
let pos = reports
|
||||
.iter()
|
||||
.position(|r| r.game_id == report.game_id && r.unlocker == report.unlocker);
|
||||
|
||||
match pos {
|
||||
Some(i) => reports[i] = report,
|
||||
None => reports.push(report),
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&reports)
|
||||
.map_err(|e| format!("Failed to serialize reports cache: {}", e))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write reports cache: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Salt management
|
||||
|
||||
fn get_or_create_salt() -> Result<String, String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
let salt = fs::read_to_string(&salt_path)
|
||||
.map_err(|e| format!("Failed to read salt file: {}", e))?;
|
||||
let salt = salt.trim().to_string();
|
||||
|
||||
if salt.len() == SALT_LENGTH {
|
||||
return Ok(salt);
|
||||
}
|
||||
|
||||
warn!("Salt file has invalid data, regenerating...");
|
||||
}
|
||||
|
||||
let salt: String = rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(SALT_LENGTH)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
fs::write(&salt_path, &salt)
|
||||
.map_err(|e| format!("Failed to write salt file: {}", e))?;
|
||||
|
||||
info!("Generated new reporting salt");
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
pub fn delete_salt() -> Result<(), String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
fs::remove_file(&salt_path)
|
||||
.map_err(|e| format!("Failed to delete salt: {}", e))?;
|
||||
info!("Deleted reporting salt (user opted out)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Hash generation
|
||||
|
||||
pub fn generate_user_hash(steam_path: &str) -> Result<String, String> {
|
||||
let machine_id = fs::read_to_string("/etc/machine-id")
|
||||
.map_err(|e| format!("Failed to read machine-id: {}", e))?;
|
||||
let machine_id = machine_id.trim();
|
||||
|
||||
let salt = get_or_create_salt()?;
|
||||
let combined = format!("{}{}{}", machine_id, steam_path, salt);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(combined.as_bytes());
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
// HTTP
|
||||
|
||||
pub async fn post_report(payload: ReportPayload) -> Result<(), String> {
|
||||
let cfg = config::load_config()?;
|
||||
|
||||
if !cfg.reporting_opted_in {
|
||||
info!("Reporting disabled - skipping report for game {}", payload.game_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/report", API_BASE);
|
||||
|
||||
info!(
|
||||
"Submitting report: game={}, unlocker={}, worked={}",
|
||||
payload.game_id, payload.unlocker, payload.worked
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send report: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
info!("Report submitted successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Report submission failed: HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
|
||||
@@ -64,6 +64,8 @@ function App() {
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleOpenRating,
|
||||
reportingEnabled,
|
||||
showToast,
|
||||
unlockerSelectionDialog,
|
||||
handleSelectCreamLinux,
|
||||
@@ -143,6 +145,8 @@ function App() {
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
onRate={handleOpenRating}
|
||||
reportingEnabled={reportingEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -190,6 +194,7 @@ function App() {
|
||||
{/* Unlocker Selection Dialog */}
|
||||
<UnlockerSelectionDialog
|
||||
visible={unlockerSelectionDialog.visible}
|
||||
gameId={unlockerSelectionDialog.gameId}
|
||||
gameTitle={unlockerSelectionDialog.gameTitle || ''}
|
||||
onClose={closeUnlockerDialog}
|
||||
onSelectCreamLinux={handleSelectCreamLinux}
|
||||
|
||||
BIN
src/assets/screenshot1.png
Normal file
BIN
src/assets/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
47
src/components/common/VotesDisplay.tsx
Normal file
47
src/components/common/VotesDisplay.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface GameVotes {
|
||||
unlocker: string
|
||||
success: number
|
||||
fail: number
|
||||
}
|
||||
|
||||
interface VotesDisplayProps {
|
||||
votes: GameVotes | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact vote bar shown inside the unlocker selection dialog.
|
||||
* Shows a green/red progress bar with a label, or "No votes yet" when empty.
|
||||
*/
|
||||
const VotesDisplay: React.FC<VotesDisplayProps> = ({ votes }) => {
|
||||
if (!votes || (votes.success === 0 && votes.fail === 0)) {
|
||||
return (
|
||||
<div className="unlocker-votes">
|
||||
<span className="votes-label votes-label--none">No votes yet</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const total = votes.success + votes.fail
|
||||
const pct = Math.round((votes.success / total) * 100)
|
||||
|
||||
const labelClass =
|
||||
pct >= 70 ? 'votes-label--positive' : pct >= 40 ? '' : 'votes-label--negative'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="unlocker-votes"
|
||||
title={`${votes.success} worked · ${votes.fail} didn't work`}
|
||||
>
|
||||
<div className="votes-bar-wrap">
|
||||
<div className="votes-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`votes-label ${labelClass}`}>
|
||||
{pct}% working ({total})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VotesDisplay
|
||||
@@ -1,6 +1,8 @@
|
||||
export { default as LoadingIndicator } from './LoadingIndicator'
|
||||
export { default as ProgressBar } from './ProgressBar'
|
||||
export { default as Dropdown } from './Dropdown'
|
||||
export { default as VotesDisplay } from './VotesDisplay'
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||
export type { DropdownOption } from './Dropdown'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
82
src/components/dialogs/OptInDialog.tsx
Normal file
82
src/components/dialogs/OptInDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface OptInDialogProps {
|
||||
visible: boolean
|
||||
onAccept: () => void
|
||||
onDecline: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* First-launch opt-in dialog for the compatibility reporting system.
|
||||
* Shown once when the app fully starts. Does not close until the user makes
|
||||
* an explicit choice.
|
||||
*/
|
||||
const OptInDialog: React.FC<OptInDialogProps> = ({ visible, onAccept, onDecline }) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={() => {}} size="medium">
|
||||
<DialogHeader onClose={() => {}} hideCloseButton={true}>
|
||||
<h3>Help improve CreamLinux</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="optin-content">
|
||||
|
||||
<p className="optin-intro">
|
||||
CreamLinux can collect anonymous compatibility reports to help users know which
|
||||
games work with CreamLinux and SmokeAPI before they install them.
|
||||
</p>
|
||||
|
||||
<div className="optin-details">
|
||||
<h4>What we collect</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>A one-way anonymous hash</strong> derived from your machine ID, Steam
|
||||
install path, and a locally-stored random salt. <em>This cannot be reversed
|
||||
to identify you</em>, and even we cannot link it to your machine.
|
||||
</li>
|
||||
<li>The Steam App ID of the game you rated.</li>
|
||||
<li>Which unlocker you used (CreamLinux or SmokeAPI).</li>
|
||||
<li>Whether it worked or not.</li>
|
||||
</ul>
|
||||
|
||||
<h4>What we do not collect</h4>
|
||||
<ul>
|
||||
<li>Your username, IP address, or any personally identifiable information.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="optin-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
If you opt out, the local salt will be deleted and no data will ever be sent.
|
||||
You will not be able to submit compatibility votes, but the app works fully
|
||||
without this feature.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onDecline}>
|
||||
No thanks
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onAccept}>
|
||||
Enable reporting
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default OptInDialog
|
||||
164
src/components/dialogs/RatingDialog.tsx
Normal file
164
src/components/dialogs/RatingDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface LocalReport {
|
||||
game_id: string
|
||||
unlocker: string
|
||||
worked: boolean
|
||||
}
|
||||
|
||||
export interface RatingDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
gameId: string
|
||||
/** 'creamlinux' | 'smokeapi' – whichever is currently installed */
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
onClose: () => void
|
||||
onSubmit: (worked: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const UNLOCKER_LABELS: Record<string, string> = {
|
||||
creamlinux: 'CreamLinux',
|
||||
smokeapi: 'SmokeAPI',
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game rating dialog. Submits exactly one report for the installed unlocker.
|
||||
*/
|
||||
const RatingDialog: React.FC<RatingDialogProps> = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
gameId,
|
||||
unlocker,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
// Which vote the user has already cast for this game+unlocker, if any
|
||||
const [previousVote, setPreviousVote] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
// Reset submit state each time the dialog opens
|
||||
setSubmitted(false)
|
||||
|
||||
// Load the local reports to see if this game+unlocker has already been started
|
||||
invoke<LocalReport[]>('get_local_reports')
|
||||
.then((reports) => {
|
||||
const existing = reports.find(
|
||||
(r) => r.game_id === gameId && r.unlocker === unlocker
|
||||
)
|
||||
setPreviousVote(existing ? existing.worked : null)
|
||||
})
|
||||
.catch(() => setPreviousVote(null))
|
||||
}, [visible, gameId, unlocker])
|
||||
|
||||
const handleSubmit = async (worked: boolean) => {
|
||||
if (submitting || submitted) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(worked)
|
||||
setSubmitted(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitted(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const label = UNLOCKER_LABELS[unlocker] ?? unlocker
|
||||
|
||||
// A button is "already chosen" if it matches the previous vote
|
||||
const workedAlreadyChosen = previousVote === true
|
||||
const brokenAlreadyChosen = previousVote === false
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleClose} size="small">
|
||||
<DialogHeader onClose={handleClose} hideCloseButton={true}>
|
||||
<h3>Submit rating</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{submitted ? (
|
||||
<div className="rating-submitted">
|
||||
<p>Thanks for your report! Your vote helps other users.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rating-content">
|
||||
<p>
|
||||
You have <strong>{label}</strong> installed for{' '}
|
||||
<strong>{gameTitle}</strong>. Did it work?
|
||||
</p>
|
||||
|
||||
{previousVote !== null && (
|
||||
<p className="rating-subtext">
|
||||
You previously voted <strong>{previousVote ? 'worked' : "didn't work"}</strong>.
|
||||
You can change your vote below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{previousVote === null && (
|
||||
<p className="rating-subtext">
|
||||
Your rating is anonymous and helps other users know if{' '}
|
||||
{label} works with this game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rating-buttons">
|
||||
<Button
|
||||
variant="success"
|
||||
className={`rating-btn rating-btn--worked${workedAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={submitting || workedAlreadyChosen}
|
||||
title={workedAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Check" variant="solid" size="sm" />}
|
||||
>
|
||||
It worked
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
className={`rating-btn rating-btn--broken${brokenAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting || brokenAlreadyChosen}
|
||||
title={brokenAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Close" variant="solid" size="sm" />}
|
||||
>
|
||||
Didn't work
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rating-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>Only the result for {label} will be submitted.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
{submitted ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RatingDialog
|
||||
110
src/components/dialogs/SmokeAPIVotesDialog.tsx
Normal file
110
src/components/dialogs/SmokeAPIVotesDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface SmokeAPIVotesDialogProps {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown before installing SmokeAPI on a Proton game.
|
||||
* Fetches and displays community votes for SmokeAPI specifically,
|
||||
* then lets the user confirm or cancel the installation.
|
||||
*/
|
||||
const SmokeAPIVotesDialog: React.FC<SmokeAPIVotesDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const [votes, setVotes] = useState<GameVotes | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => setVotes(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [visible, gameId])
|
||||
|
||||
const hasVotes = votes && (votes.success > 0 || votes.fail > 0)
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>Install SmokeAPI</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-votes-content">
|
||||
<p className="smokeapi-votes-game">
|
||||
<strong>{gameTitle}</strong>
|
||||
</p>
|
||||
|
||||
<div className="smokeapi-votes-section">
|
||||
<p className="smokeapi-votes-label">Community compatibility</p>
|
||||
{loading ? (
|
||||
<p className="smokeapi-votes-loading">Fetching votes...</p>
|
||||
) : (
|
||||
<VotesDisplay votes={votes} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && !hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
No one has rated this game yet. You'll be able to submit a rating after
|
||||
installing.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="sm" />
|
||||
<span>
|
||||
These ratings are from other CreamLinux users. Results may vary.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onConfirm}>
|
||||
Install anyway
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPIVotesDialog
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
@@ -8,10 +9,12 @@ import {
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface UnlockerSelectionDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onClose: () => void
|
||||
onSelectCreamLinux: () => void
|
||||
onSelectSmokeAPI: () => void
|
||||
@@ -19,15 +22,39 @@ export interface UnlockerSelectionDialogProps {
|
||||
|
||||
/**
|
||||
* Unlocker Selection Dialog component
|
||||
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games
|
||||
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games.
|
||||
* Fetches and displays community vote data per unlocker.
|
||||
*/
|
||||
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onClose,
|
||||
onSelectCreamLinux,
|
||||
onSelectSmokeAPI,
|
||||
}) => {
|
||||
const [creamVotes, setCreamVotes] = useState<GameVotes | null>(null)
|
||||
const [smokeVotes, setSmokeVotes] = useState<GameVotes | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setCreamVotes(results.find((v) => v.unlocker === 'creamlinux') ?? null)
|
||||
setSmokeVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Votes are non-critical — silently fall back to "No votes yet"
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
})
|
||||
}, [visible, gameId])
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
@@ -52,6 +79,7 @@ const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||
Native Linux DLC unlocker. Works best with most native Linux games and provides
|
||||
better compatibility.
|
||||
</p>
|
||||
<VotesDisplay votes={creamVotes} />
|
||||
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
|
||||
Install CreamLinux
|
||||
</Button>
|
||||
@@ -66,6 +94,7 @@ const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
|
||||
Automatically fetches DLC information.
|
||||
</p>
|
||||
<VotesDisplay votes={smokeVotes} />
|
||||
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
|
||||
Install SmokeAPI
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,10 @@ export { default as SettingsDialog } from './SettingsDialog'
|
||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||
export { default as ConflictDialog } from './ConflictDialog'
|
||||
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||
export { default as UnlockerSelectionDialog} from './UnlockerSelectionDialog'
|
||||
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
||||
export { default as OptInDialog } from './OptInDialog'
|
||||
export { default as RatingDialog } from './RatingDialog'
|
||||
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog'
|
||||
@@ -24,3 +27,5 @@ export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
||||
export type { AddDlcDialogProps } from './AddDlcDialog'
|
||||
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
|
||||
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
|
||||
export type { RatingDialogProps } from './RatingDialog'
|
||||
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'
|
||||
@@ -9,13 +9,15 @@ interface GameItemProps {
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
reportingEnabled?: boolean // When false/undefined, rate button is not rendered at all.
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual game card component
|
||||
* Displays game information and action buttons
|
||||
*/
|
||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
|
||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
@@ -93,6 +95,13 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Rating handler
|
||||
const handleRate = () => {
|
||||
if (onRate && (game.cream_installed || game.smoke_installed)) {
|
||||
onRate(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
@@ -179,6 +188,20 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate button */}
|
||||
{(game.cream_installed || game.smoke_installed) && onRate && reportingEnabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleRate}
|
||||
disabled={!!game.installing}
|
||||
title="Rate compatibility"
|
||||
className="edit-button rate-button"
|
||||
leftIcon={<Icon name="Star" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<Button
|
||||
|
||||
@@ -10,13 +10,15 @@ interface GameListProps {
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
reportingEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: GameListProps) => {
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
@@ -57,7 +59,7 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: Ga
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const warning = 'Warning'
|
||||
export const wine = 'Wine'
|
||||
export const diamond = 'Diamond'
|
||||
export const settings = 'Settings'
|
||||
export const star = 'Star'
|
||||
|
||||
// Brand icons
|
||||
export const discord = 'Discord'
|
||||
@@ -59,6 +60,7 @@ export const IconNames = {
|
||||
Wine: wine,
|
||||
Diamond: diamond,
|
||||
Settings: settings,
|
||||
Star: star,
|
||||
|
||||
// Brand icons
|
||||
Discord: discord,
|
||||
|
||||
@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
|
||||
export { ReactComponent as Refresh } from './refresh.svg'
|
||||
export { ReactComponent as Search } from './search.svg'
|
||||
export { ReactComponent as Settings } from './settings.svg'
|
||||
export { ReactComponent as Star } from './star.svg'
|
||||
export { ReactComponent as Trash } from './trash.svg'
|
||||
export { ReactComponent as Warning } from './warning.svg'
|
||||
export { ReactComponent as Wine } from './wine.svg'
|
||||
3
src/components/icons/ui/solid/star.svg
Normal file
3
src/components/icons/ui/solid/star.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none">
|
||||
<path d="M11.9961 1.25C13.0454 1.25 13.8719 2.04253 14.3995 3.11191L16.1616 6.66516C16.215 6.77513 16.3417 6.92998 16.5321 7.07164C16.7223 7.21315 16.9086 7.29121 17.0311 7.3118L20.2207 7.84613C21.3729 8.03973 22.3386 8.60449 22.6521 9.5879C22.9653 10.5705 22.5064 11.5916 21.6778 12.4216L21.677 12.4225L19.1991 14.9209C19.1009 15.0199 18.9909 15.2064 18.9219 15.4494C18.8534 15.6908 18.8473 15.9107 18.8784 16.0527L18.8788 16.0547L19.5877 19.1454C19.8818 20.4317 19.7843 21.7073 18.8771 22.3742C17.9667 23.0433 16.7227 22.7467 15.5925 22.0736L12.6026 20.289C12.477 20.214 12.2614 20.1532 12.0011 20.1532C11.7427 20.1532 11.5226 20.2132 11.3888 20.291L11.3869 20.2921L8.40288 22.0732C7.27405 22.7487 6.03154 23.04 5.12111 22.3702C4.21449 21.7032 4.11214 20.43 4.40711 19.1447L5.1159 16.0547L5.11633 16.0527C5.14741 15.9107 5.14133 15.6908 5.0728 15.4494C5.0038 15.2064 4.89379 15.0199 4.79558 14.9209L2.31585 12.4206C1.49265 11.5906 1.03521 10.5704 1.34595 9.58925C1.65759 8.60525 2.62143 8.0398 3.77433 7.84606L6.96132 7.31219L6.96233 7.31202C7.07917 7.29175 7.2627 7.21456 7.45248 7.07268C7.64261 6.93054 7.76959 6.77535 7.82312 6.66516L7.82582 6.65967L9.58562 3.11097L9.58632 3.10957C10.119 2.04108 10.948 1.25 11.9961 1.25Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
|
||||
export { ReactComponent as Refresh } from './refresh.svg'
|
||||
export { ReactComponent as Search } from './search.svg'
|
||||
export { ReactComponent as Settings } from './settings.svg'
|
||||
export { ReactComponent as Star } from './star.svg'
|
||||
export { ReactComponent as Trash } from './trash.svg'
|
||||
export { ReactComponent as Warning } from './warning.svg'
|
||||
export { ReactComponent as Wine } from './wine.svg'
|
||||
3
src/components/icons/ui/stroke/star.svg
Normal file
3
src/components/icons/ui/stroke/star.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.7276 3.44418L15.4874 6.99288C15.7274 7.48687 16.3673 7.9607 16.9073 8.05143L20.0969 8.58575C22.1367 8.92853 22.6167 10.4206 21.1468 11.8925L18.6671 14.3927C18.2471 14.8161 18.0172 15.6327 18.1471 16.2175L18.8571 19.3125C19.417 21.7623 18.1271 22.71 15.9774 21.4296L12.9877 19.6452C12.4478 19.3226 11.5579 19.3226 11.0079 19.6452L8.01827 21.4296C5.8785 22.71 4.57865 21.7522 5.13859 19.3125L5.84851 16.2175C5.97849 15.6327 5.74852 14.8161 5.32856 14.3927L2.84884 11.8925C1.389 10.4206 1.85895 8.92853 3.89872 8.58575L7.08837 8.05143C7.61831 7.9607 8.25824 7.48687 8.49821 6.99288L10.258 3.44418C11.2179 1.51861 12.7777 1.51861 13.7276 3.44418Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -26,6 +26,14 @@ export interface SmokeAPISettingsDialogState {
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
export interface RatingDialogState {
|
||||
visible: boolean
|
||||
gameId: string
|
||||
gameTitle: string
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
steamPath: string
|
||||
}
|
||||
|
||||
// Define the context type
|
||||
export interface AppContextType {
|
||||
// Game state
|
||||
@@ -56,6 +64,22 @@ export interface AppContextType {
|
||||
handleSmokeAPISettingsOpen: (gameId: string) => void
|
||||
handleSmokeAPISettingsClose: () => void
|
||||
|
||||
// SmokeAPI votes dialog
|
||||
smokeAPIVotesDialog: {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}
|
||||
handleSmokeAPIVotesClose: () => void
|
||||
handleSmokeAPIVotesConfirm: () => void
|
||||
|
||||
// Rating dialog
|
||||
ratingDialog: RatingDialogState
|
||||
handleOpenRating: (gameId: string) => void
|
||||
handleCloseRating: () => void
|
||||
handleSubmitRating: (worked: boolean) => Promise<void>
|
||||
reportingEnabled: boolean
|
||||
|
||||
// Toast notifications
|
||||
showToast: (
|
||||
message: string,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { AppContext, AppContextType } from './AppContext'
|
||||
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||
import { DlcInfo } from '@/types'
|
||||
import { DlcInfo, Config } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { ToastContainer } from '@/components/notifications'
|
||||
import { SmokeAPISettingsDialog } from '@/components/dialogs'
|
||||
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog } from '@/components/dialogs'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Context provider component
|
||||
interface AppProviderProps {
|
||||
@@ -53,6 +54,47 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
gameTitle: '',
|
||||
})
|
||||
|
||||
// SmokeAPI votes dialog state
|
||||
const [smokeAPIVotesDialog, setSmokeAPIVotesDialog] = useState<{
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}>({
|
||||
visible: false,
|
||||
gameId: null,
|
||||
gameTitle: null,
|
||||
})
|
||||
|
||||
// Opt-in dialog state
|
||||
const [optInDialog, setOptInDialog] = useState(false)
|
||||
const [reportingEnabled, setReportingEnabled] = useState(false)
|
||||
|
||||
// Rating dialog state
|
||||
const [ratingDialog, setRatingDialog] = useState<{
|
||||
visible: boolean
|
||||
gameId: string
|
||||
gameTitle: string
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
steamPath: string
|
||||
}>({
|
||||
visible: false,
|
||||
gameId: '',
|
||||
gameTitle: '',
|
||||
unlocker: 'creamlinux',
|
||||
steamPath: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
invoke<Config>('load_config')
|
||||
.then((cfg) => {
|
||||
setReportingEnabled(cfg.reporting_opted_in)
|
||||
if (!cfg.reporting_has_seen_prompt) {
|
||||
setOptInDialog(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load config for reporting check:', err))
|
||||
}, [])
|
||||
|
||||
// Settings handlers
|
||||
const handleSettingsOpen = () => {
|
||||
setSettingsDialog({ visible: true })
|
||||
@@ -85,6 +127,69 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSmokeAPIVotesClose = () => {
|
||||
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||
}
|
||||
|
||||
const handleSmokeAPIVotesConfirm = () => {
|
||||
const gameId = smokeAPIVotesDialog.gameId
|
||||
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||
if (gameId) {
|
||||
// Now actually run the install
|
||||
executeGameAction(gameId, 'install_smoke', games)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptInAccept = async () => {
|
||||
try {
|
||||
await invoke('set_reporting_opt_in', { optedIn: true })
|
||||
setReportingEnabled(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to save reporting opt-in:', err)
|
||||
}
|
||||
setOptInDialog(false)
|
||||
}
|
||||
|
||||
const handleOptInDecline = async () => {
|
||||
try {
|
||||
await invoke('set_reporting_opt_in', { optedIn: false })
|
||||
setReportingEnabled(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save reporting opt-out:', err)
|
||||
}
|
||||
setOptInDialog(false)
|
||||
}
|
||||
|
||||
const handleOpenRating = (gameId: string) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (!game) return
|
||||
|
||||
setRatingDialog({
|
||||
visible: true,
|
||||
gameId,
|
||||
gameTitle: game.title,
|
||||
unlocker: game.cream_installed ? 'creamlinux' : 'smokeapi',
|
||||
steamPath: game.path,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseRating = () => {
|
||||
setRatingDialog((prev) => ({ ...prev, visible: false }))
|
||||
}
|
||||
|
||||
const handleSubmitRating = async (worked: boolean) => {
|
||||
try {
|
||||
await invoke('submit_report', {
|
||||
gameId: ratingDialog.gameId,
|
||||
unlocker: ratingDialog.unlocker,
|
||||
worked,
|
||||
steamPath: ratingDialog.steamPath,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to submit rating:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Game action handler with proper error reporting
|
||||
const handleGameAction = async (gameId: string, action: ActionType) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
@@ -117,6 +222,16 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// intercept install_smoke for votes dialog
|
||||
if (action === 'install_smoke' && !game.native) {
|
||||
setSmokeAPIVotesDialog({
|
||||
visible: true,
|
||||
gameId: game.id,
|
||||
gameTitle: game.title,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For install_unlocker action, executeGameAction will handle showing the dialog
|
||||
// We should NOT show any notifications here - they'll be shown after actual installation
|
||||
if (action === 'install_unlocker') {
|
||||
@@ -267,6 +382,18 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleSmokeAPISettingsClose,
|
||||
|
||||
// SmokeAPI Votes
|
||||
smokeAPIVotesDialog,
|
||||
handleSmokeAPIVotesClose,
|
||||
handleSmokeAPIVotesConfirm,
|
||||
|
||||
// Rating
|
||||
ratingDialog,
|
||||
handleOpenRating,
|
||||
handleCloseRating,
|
||||
handleSubmitRating,
|
||||
reportingEnabled,
|
||||
|
||||
// Toast notifications
|
||||
showToast,
|
||||
|
||||
@@ -330,6 +457,32 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
gamePath={smokeAPISettingsDialog.gamePath}
|
||||
gameTitle={smokeAPISettingsDialog.gameTitle}
|
||||
/>
|
||||
|
||||
{/* SmokeAPI Votes Dialog */}
|
||||
<SmokeAPIVotesDialog
|
||||
visible={smokeAPIVotesDialog.visible}
|
||||
gameId={smokeAPIVotesDialog.gameId}
|
||||
gameTitle={smokeAPIVotesDialog.gameTitle}
|
||||
onClose={handleSmokeAPIVotesClose}
|
||||
onConfirm={handleSmokeAPIVotesConfirm}
|
||||
/>
|
||||
|
||||
{/* Opt-in Dialog */}
|
||||
<OptInDialog
|
||||
visible={optInDialog}
|
||||
onAccept={handleOptInAccept}
|
||||
onDecline={handleOptInDecline}
|
||||
/>
|
||||
|
||||
{/* Rating Dialog */}
|
||||
<RatingDialog
|
||||
visible={ratingDialog.visible}
|
||||
gameId={ratingDialog.gameId}
|
||||
gameTitle={ratingDialog.gameTitle}
|
||||
unlocker={ratingDialog.unlocker}
|
||||
onClose={handleCloseRating}
|
||||
onSubmit={handleSubmitRating}
|
||||
/>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -160,3 +160,33 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Rating button on game card
|
||||
.rate-button {
|
||||
svg {
|
||||
color: var(--elevated-bg);
|
||||
transition: transform var(--duration-normal) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.28);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.25);
|
||||
|
||||
svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@forward './loading';
|
||||
@forward './progress_bar';
|
||||
@forward './dropdown';
|
||||
@forward './votes_display';
|
||||
|
||||
43
src/styles/components/common/_votes_display.scss
Normal file
43
src/styles/components/common/_votes_display.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
.unlocker-votes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
|
||||
.votes-bar-wrap {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
|
||||
.votes-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--success);
|
||||
border-radius: 99px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.votes-label {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
|
||||
&.votes-label--positive {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
&.votes-label--negative {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&.votes-label--none {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,6 @@
|
||||
@forward './conflict_dialog';
|
||||
@forward './disclaimer_dialog';
|
||||
@forward './unlocker_selection_dialog';
|
||||
@forward './optin_dialog';
|
||||
@forward './rating_dialog';
|
||||
@forward './smokeapi_votes_dialog';
|
||||
|
||||
84
src/styles/components/dialogs/_optin_dialog.scss
Normal file
84
src/styles/components/dialogs/_optin_dialog.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
.optin-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.optin-icon-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--info);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.optin-intro {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.optin-details {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
h4 {
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--bold);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
|
||||
li {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
em {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.optin-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--info-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--info);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/styles/components/dialogs/_rating_dialog.scss
Normal file
97
src/styles/components/dialogs/_rating_dialog.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
.rating-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.rating-subtext {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.rating-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rating-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
font-weight: var(--bold);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&--worked {
|
||||
background-color: var(--success);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--success-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(140, 200, 147, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&--broken {
|
||||
background-color: var(--danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rating-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--info);
|
||||
}
|
||||
}
|
||||
|
||||
.rating-submitted {
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
57
src/styles/components/dialogs/_smokeapi_votes_dialog.scss
Normal file
57
src/styles/components/dialogs/_smokeapi_votes_dialog.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
.smokeapi-votes-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.smokeapi-votes-game {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.smokeapi-votes-section {
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.smokeapi-votes-label {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.smokeapi-votes-loading {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.smokeapi-votes-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--info);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,6 @@
|
||||
export interface Config {
|
||||
/** Whether to show the disclaimer on startup */
|
||||
show_disclaimer: boolean
|
||||
reporting_opted_in: boolean
|
||||
reporting_has_seen_prompt: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user