20 Commits

Author SHA1 Message Date
Tickbase
220763b389 Merge pull request #109 from naguiagahnim/main
Package app for Nix
2026-04-29 14:19:43 +02:00
Agahnim
3d894266a7 Add Nix installation instructions to README.md 2026-04-29 14:13:47 +02:00
Agahnim
33492a6a55 nix: Init package 2026-04-29 13:43:32 +02:00
Agahnim
92f4d82e6c Update package-lock.json 2026-04-29 10:52:33 +02:00
Tickbase
5896733fd4 cargo.lock per request #105 2026-04-23 15:40:18 +02:00
Tickbase
1d72f24afa gitignore 2026-04-23 15:40:09 +02:00
Tickbase
aea8a84335 update demo in README.md 2026-03-28 15:19:13 +00:00
Tickbase
a476819312 Add files via upload 2026-03-28 15:18:29 +00:00
Novattz
1bb62877a3 version bump 2026-03-28 15:08:40 +01:00
Novattz
f8ea256637 changelog 2026-03-28 15:08:36 +01:00
Novattz
0480d523e3 stuff 2026-03-28 15:07:50 +01:00
Novattz
1571e9d87d backend for reporting and commands #22 2026-03-28 15:07:37 +01:00
Novattz
f949ecf2f3 new config options 2026-03-28 15:07:23 +01:00
Novattz
ecee6529ff export get_cache_dir 2026-03-28 15:07:12 +01:00
Novattz
d9819ef115 new packages 2026-03-28 15:06:38 +01:00
Novattz
ff53cc7a46 styling 2026-03-28 15:06:33 +01:00
Novattz
1a1c7dfb3d Vote display #22 2026-03-28 15:06:20 +01:00
Novattz
769213288e reflect votes in dialogs #22 2026-03-28 15:06:09 +01:00
Novattz
85d670931a Rate & opt-in dialog #22 2026-03-28 15:05:57 +01:00
Novattz
487e974274 New icon 2026-03-28 15:05:27 +01:00
39 changed files with 10130 additions and 2614 deletions

1
.gitignore vendored
View File

@@ -12,7 +12,6 @@ dist
dist-ssr
docs
*.local
*.lock
.env
# Editor directories and files

View File

@@ -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

View File

@@ -4,7 +4,7 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
## Watch the demo here:
[![Watch the demo](./src/assets/screenshot.png)](https://www.youtube.com/watch?v=ZunhZnKFLlg)
[![Watch the demo](./src/assets/screenshot1.png)](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
View 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;
}

4683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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::{

View File

@@ -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,
}
}
}
@@ -63,11 +68,50 @@ pub fn load_config() -> Result<Config, String> {
// Read and parse config file
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);
}
info!("Loaded config from {:?}", config_path);
Ok(config)
}

View File

@@ -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
View 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()))
}
}

View File

@@ -19,7 +19,7 @@
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "1.4.2",
"version": "1.5.0",
"identifier": "com.creamlinux.dev",
"app": {
"withGlobalTauri": false,

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View 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

View File

@@ -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 { DropdownOption } from './Dropdown'
export type { GameVotes } from './VotesDisplay'

View 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

View 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

View 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

View File

@@ -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>

View File

@@ -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'
@@ -23,4 +26,6 @@ export type { ProgressDialogProps, InstallationInstructions } from './ProgressDi
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { AddDlcDialogProps } from './AddDlcDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
export type { RatingDialogProps } from './RatingDialog'
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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,

View File

@@ -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'

View 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

View File

@@ -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'

View 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

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -1,3 +1,4 @@
@forward './loading';
@forward './progress_bar';
@forward './dropdown';
@forward './votes_display';

View 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);
}
}
}

View File

@@ -6,3 +6,6 @@
@forward './conflict_dialog';
@forward './disclaimer_dialog';
@forward './unlocker_selection_dialog';
@forward './optin_dialog';
@forward './rating_dialog';
@forward './smokeapi_votes_dialog';

View 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;
}
}
}

View 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;
}
}

View 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;
}
}
}

View File

@@ -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
}