mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-01-31 07:42:52 -05:00
Compare commits
12 Commits
v1.3.0
...
a00cc92b70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00cc92b70 | ||
|
|
85520f8916 | ||
|
|
ac96e7be69 | ||
|
|
3675ff8fae | ||
|
|
ab057b8d10 | ||
|
|
952749cc93 | ||
|
|
4c4e087be7 | ||
|
|
1e52c2071c | ||
|
|
fc8c69a915 | ||
|
|
2d7077a05b | ||
|
|
081d61afc7 | ||
|
|
0bfd36aea9 |
2764
package-lock.json
generated
2764
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,14 +40,14 @@
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"glob": "^11.0.2",
|
||||
"glob": "^11.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"semantic-release": "^24.2.4",
|
||||
"semantic-release": "^25.0.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod dlc_manager;
|
||||
mod installer;
|
||||
mod searcher;
|
||||
mod unlockers;
|
||||
mod smokeapi_config;
|
||||
|
||||
use dlc_manager::DlcInfoWithState;
|
||||
use installer::{Game, InstallerAction, InstallerType};
|
||||
@@ -434,6 +435,27 @@ async fn install_cream_with_dlcs_command(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_smokeapi_config(game_path: String) -> Result<Option<smokeapi_config::SmokeAPIConfig>, String> {
|
||||
info!("Reading SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::read_config(&game_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_smokeapi_config(
|
||||
game_path: String,
|
||||
config: smokeapi_config::SmokeAPIConfig,
|
||||
) -> Result<(), String> {
|
||||
info!("Writing SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::write_config(&game_path, &config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_smokeapi_config(game_path: String) -> Result<(), String> {
|
||||
info!("Deleting SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::delete_config(&game_path)
|
||||
}
|
||||
|
||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
@@ -491,6 +513,9 @@ fn main() {
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
read_smokeapi_config,
|
||||
write_smokeapi_config,
|
||||
delete_smokeapi_config,
|
||||
])
|
||||
.setup(|app| {
|
||||
info!("Tauri application setup");
|
||||
|
||||
128
src-tauri/src/smokeapi_config.rs
Normal file
128
src-tauri/src/smokeapi_config.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SmokeAPIConfig {
|
||||
#[serde(rename = "$schema")]
|
||||
pub schema: String,
|
||||
#[serde(rename = "$version")]
|
||||
pub version: u32,
|
||||
pub logging: bool,
|
||||
pub log_steam_http: bool,
|
||||
pub default_app_status: String,
|
||||
pub override_app_status: HashMap<String, String>,
|
||||
pub override_dlc_status: HashMap<String, String>,
|
||||
pub auto_inject_inventory: bool,
|
||||
pub extra_inventory_items: Vec<u32>,
|
||||
pub extra_dlcs: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for SmokeAPIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: "https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json".to_string(),
|
||||
version: 4,
|
||||
logging: false,
|
||||
log_steam_http: false,
|
||||
default_app_status: "unlocked".to_string(),
|
||||
override_app_status: HashMap::new(),
|
||||
override_dlc_status: HashMap::new(),
|
||||
auto_inject_inventory: true,
|
||||
extra_inventory_items: Vec::new(),
|
||||
extra_dlcs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read SmokeAPI config from a game directory
|
||||
// Returns None if the config doesn't exist
|
||||
pub fn read_config(game_path: &str) -> Result<Option<SmokeAPIConfig>, String> {
|
||||
info!("Reading SmokeAPI config from: {}", game_path);
|
||||
|
||||
// Find the SmokeAPI DLL location in the game directory
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
if !config_path.exists() {
|
||||
info!("No SmokeAPI config found at: {}", config_path.display());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read SmokeAPI config: {}", e))?;
|
||||
|
||||
let config: SmokeAPIConfig = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse SmokeAPI config: {}", e))?;
|
||||
|
||||
info!("Successfully read SmokeAPI config");
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
// Write SmokeAPI config to a game directory
|
||||
pub fn write_config(game_path: &str, config: &SmokeAPIConfig) -> Result<(), String> {
|
||||
info!("Writing SmokeAPI config to: {}", game_path);
|
||||
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize SmokeAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write SmokeAPI config: {}", e))?;
|
||||
|
||||
info!("Successfully wrote SmokeAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Delete SmokeAPI config from a game directory
|
||||
pub fn delete_config(game_path: &str) -> Result<(), String> {
|
||||
info!("Deleting SmokeAPI config from: {}", game_path);
|
||||
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
if config_path.exists() {
|
||||
fs::remove_file(&config_path)
|
||||
.map_err(|e| format!("Failed to delete SmokeAPI config: {}", e))?;
|
||||
info!("Successfully deleted SmokeAPI config");
|
||||
} else {
|
||||
info!("No SmokeAPI config to delete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Find the path where SmokeAPI.config.json should be located
|
||||
// This is in the same directory as the SmokeAPI DLL files
|
||||
fn find_smokeapi_config_path(game_path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// Search for steam_api*.dll files with _o.dll backups (indicating SmokeAPI installation)
|
||||
let mut smokeapi_dir: Option<std::path::PathBuf> = None;
|
||||
|
||||
// Use walkdir to search recursively
|
||||
for entry in walkdir::WalkDir::new(game_path_obj)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Look for steam_api*_o.dll (backup files created by SmokeAPI)
|
||||
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
|
||||
smokeapi_dir = path.parent().map(|p| p.to_path_buf());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a SmokeAPI directory, return the config path
|
||||
if let Some(dir) = smokeapi_dir {
|
||||
Ok(dir.join("SmokeAPI.config.json"))
|
||||
} else {
|
||||
// Fallback to game root directory
|
||||
warn!("Could not find SmokeAPI DLL directory, using game root");
|
||||
Ok(game_path_obj.join("SmokeAPI.config.json"))
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function App() {
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
handleSmokeAPISettingsOpen,
|
||||
} = useAppContext()
|
||||
|
||||
// Show update screen first
|
||||
@@ -86,6 +87,7 @@ function App() {
|
||||
isLoading={isLoading}
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
97
src/components/common/Dropdown.tsx
Normal file
97
src/components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Icon, arrowUp } from '@/components/icons'
|
||||
|
||||
export interface DropdownOption<T = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
interface DropdownProps<T = string> {
|
||||
label: string
|
||||
description?: string
|
||||
value: T
|
||||
options: DropdownOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown component for selecting from a list of options
|
||||
*/
|
||||
const Dropdown = <T extends string | number | boolean>({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: DropdownProps<T>) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const handleSelect = (optionValue: T) => {
|
||||
onChange(optionValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`dropdown-container ${className}`}>
|
||||
<div className="dropdown-label-container">
|
||||
<label className="dropdown-label">{label}</label>
|
||||
{description && <p className="dropdown-description">{description}</p>}
|
||||
</div>
|
||||
|
||||
<div className={`dropdown ${disabled ? 'disabled' : ''}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="dropdown-trigger"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="dropdown-value">{selectedOption?.label || 'Select...'}</span>
|
||||
<Icon
|
||||
name={arrowUp}
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className={`dropdown-icon ${isOpen ? 'open' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="dropdown-menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
className={`dropdown-option ${option.value === value ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dropdown
|
||||
@@ -1,4 +1,6 @@
|
||||
export { default as LoadingIndicator } from './LoadingIndicator'
|
||||
export { default as ProgressBar } from './ProgressBar'
|
||||
export { default as Dropdown } from './Dropdown'
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||
export type { DropdownOption } from './Dropdown'
|
||||
@@ -41,7 +41,7 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) =>
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
<Icon name={settings} variant="solid" size="md" />
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
228
src/components/dialogs/SmokeAPISettingsDialog.tsx
Normal file
228
src/components/dialogs/SmokeAPISettingsDialog.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { Dropdown, DropdownOption } from '@/components/common'
|
||||
//import { Icon, settings } from '@/components/icons'
|
||||
|
||||
interface SmokeAPIConfig {
|
||||
$schema: string
|
||||
$version: number
|
||||
logging: boolean
|
||||
log_steam_http: boolean
|
||||
default_app_status: 'unlocked' | 'locked' | 'original'
|
||||
override_app_status: Record<string, string>
|
||||
override_dlc_status: Record<string, string>
|
||||
auto_inject_inventory: boolean
|
||||
extra_inventory_items: number[]
|
||||
extra_dlcs: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SmokeAPISettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SmokeAPIConfig = {
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json',
|
||||
$version: 4,
|
||||
logging: false,
|
||||
log_steam_http: false,
|
||||
default_app_status: 'unlocked',
|
||||
override_app_status: {},
|
||||
override_dlc_status: {},
|
||||
auto_inject_inventory: true,
|
||||
extra_inventory_items: [],
|
||||
extra_dlcs: {},
|
||||
}
|
||||
|
||||
const APP_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
/**
|
||||
* SmokeAPI Settings Dialog
|
||||
* Allows configuration of SmokeAPI for a specific game
|
||||
*/
|
||||
const SmokeAPISettingsDialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
gamePath,
|
||||
gameTitle,
|
||||
}: SmokeAPISettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<SmokeAPIConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Load existing config when dialog opens
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<SmokeAPIConfig | null>('read_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to load SmokeAPI config:', error)
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
// Save the config
|
||||
await invoke('write_smokeapi_config', {
|
||||
gamePath,
|
||||
config,
|
||||
})
|
||||
} else {
|
||||
// Delete the config
|
||||
await invoke('delete_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save SmokeAPI config:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof SmokeAPIConfig>(key: K, value: SmokeAPIConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleCancel} size="medium">
|
||||
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>SmokeAPI Settings</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-settings-content">
|
||||
{/* Enable/Disable Section */}
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label="Enable SmokeAPI Configuration"
|
||||
sublabel="Enable this to customize SmokeAPI settings for this game"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Options */}
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
|
||||
<Dropdown
|
||||
label="Default App Status"
|
||||
description="Specifies the default DLC status"
|
||||
value={config.default_app_status}
|
||||
options={APP_STATUS_OPTIONS}
|
||||
onChange={(value) => updateConfig('default_app_status', value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Logging</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.logging}
|
||||
onChange={() => updateConfig('logging', !config.logging)}
|
||||
label="Enable Logging"
|
||||
sublabel="Enables logging to SmokeAPI.log.log file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.log_steam_http}
|
||||
onChange={() => updateConfig('log_steam_http', !config.log_steam_http)}
|
||||
label="Log Steam HTTP"
|
||||
sublabel="Toggles logging of SteamHTTP traffic"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Inventory</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.auto_inject_inventory}
|
||||
onChange={() =>
|
||||
updateConfig('auto_inject_inventory', !config.auto_inject_inventory)
|
||||
}
|
||||
label="Auto Inject Inventory"
|
||||
sublabel="Automatically inject a list of all registered inventory items when the game queries user inventory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPISettingsDialog
|
||||
@@ -7,6 +7,7 @@ export { default as DialogActions } from './DialogActions'
|
||||
export { default as ProgressDialog } from './ProgressDialog'
|
||||
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
|
||||
export { default as SettingsDialog } from './SettingsDialog'
|
||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog'
|
||||
|
||||
@@ -8,13 +8,14 @@ interface GameItemProps {
|
||||
game: Game
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual game card component
|
||||
* Displays game information and action buttons
|
||||
*/
|
||||
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
@@ -77,6 +78,13 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// SmokeAPI settings handler
|
||||
const handleSmokeAPISettings = () => {
|
||||
if (onSmokeAPISettings && game.smoke_installed) {
|
||||
onSmokeAPISettings(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
@@ -156,6 +164,20 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if SmokeAPI is installed */}
|
||||
{game.smoke_installed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleSmokeAPISettings}
|
||||
disabled={!game.smoke_installed || !!game.installing}
|
||||
title="Configure SmokeAPI"
|
||||
className="edit-button settings-icon-button"
|
||||
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,14 @@ interface GameListProps {
|
||||
isLoading: boolean
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
@@ -56,7 +57,7 @@ const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface ProgressDialogState {
|
||||
instructions?: InstallationInstructions
|
||||
}
|
||||
|
||||
export interface SmokeAPISettingsDialogState {
|
||||
visible: boolean
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
// Define the context type
|
||||
export interface AppContextType {
|
||||
// Game state
|
||||
@@ -54,6 +60,11 @@ export interface AppContextType {
|
||||
handleSettingsOpen: () => void
|
||||
handleSettingsClose: () => void
|
||||
|
||||
// SmokeAPI settings
|
||||
smokeAPISettingsDialog: SmokeAPISettingsDialogState
|
||||
handleSmokeAPISettingsOpen: (gameId: string) => void
|
||||
handleSmokeAPISettingsClose: () => void
|
||||
|
||||
// Toast notifications
|
||||
showToast: (
|
||||
message: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||
import { DlcInfo } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { ToastContainer } from '@/components/notifications'
|
||||
import { SmokeAPISettingsDialog } from '@/components/dialogs'
|
||||
|
||||
// Context provider component
|
||||
interface AppProviderProps {
|
||||
@@ -38,6 +39,17 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
// Settings dialog state
|
||||
const [settingsDialog, setSettingsDialog] = useState({ visible: false })
|
||||
|
||||
// SmokeAPI settings dialog state
|
||||
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
|
||||
visible: boolean
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}>({
|
||||
visible: false,
|
||||
gamePath: '',
|
||||
gameTitle: '',
|
||||
})
|
||||
|
||||
// Settings handlers
|
||||
const handleSettingsOpen = () => {
|
||||
setSettingsDialog({ visible: true })
|
||||
@@ -47,6 +59,25 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
setSettingsDialog({ visible: false })
|
||||
}
|
||||
|
||||
// SmokeAPI settings handlers
|
||||
const handleSmokeAPISettingsOpen = (gameId: string) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (!game) {
|
||||
showError('Game not found')
|
||||
return
|
||||
}
|
||||
|
||||
setSmokeAPISettingsDialog({
|
||||
visible: true,
|
||||
gamePath: game.path,
|
||||
gameTitle: game.title,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSmokeAPISettingsClose = () => {
|
||||
setSmokeAPISettingsDialog((prev) => ({ ...prev, visible: false }))
|
||||
}
|
||||
|
||||
// Game action handler with proper error reporting
|
||||
const handleGameAction = async (gameId: string, action: ActionType) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
@@ -201,6 +232,11 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
|
||||
// SmokeAPI Settings
|
||||
smokeAPISettingsDialog,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleSmokeAPISettingsClose,
|
||||
|
||||
// Toast notifications
|
||||
showToast,
|
||||
}
|
||||
@@ -209,6 +245,14 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
<AppContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onDismiss={removeToast} />
|
||||
|
||||
{/* SmokeAPI Settings Dialog */}
|
||||
<SmokeAPISettingsDialog
|
||||
visible={smokeAPISettingsDialog.visible}
|
||||
onClose={handleSmokeAPISettingsClose}
|
||||
gamePath={smokeAPISettingsDialog.gamePath}
|
||||
gameTitle={smokeAPISettingsDialog.gameTitle}
|
||||
/>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
127
src/styles/components/common/_dropdown.scss
Normal file
127
src/styles/components/common/_dropdown.scss
Normal file
@@ -0,0 +1,127 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
Dropdown component styles
|
||||
*/
|
||||
|
||||
.dropdown-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-label-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--border-dark);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--border);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-value {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
transition: transform var(--duration-normal) var(--easing-ease-out);
|
||||
color: var(--text-secondary);
|
||||
transform: rotate(180deg);
|
||||
|
||||
&.open {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--elevated-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-modal);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(var(--primary-color), 0.2);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
@forward './loading';
|
||||
@forward './progress_bar';
|
||||
@forward './dropdown';
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
@forward './dlc_dialog';
|
||||
@forward './progress_dialog';
|
||||
@forward './settings_dialog';
|
||||
@forward './smokeapi_settings_dialog';
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
.settings-section {
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
66
src/styles/components/dialogs/_smokeapi_settings_dialog.scss
Normal file
66
src/styles/components/dialogs/_smokeapi_settings_dialog.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
SmokeAPI Settings Dialog styles
|
||||
*/
|
||||
|
||||
.dialog-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.smokeapi-settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
transition: opacity var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-option {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.animated-checkbox {
|
||||
width: 100%;
|
||||
|
||||
.checkbox-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-sublabel {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user