12 Commits

Author SHA1 Message Date
Novattz
a00cc92b70 adjust settings dialog 2025-12-23 02:00:09 +01:00
Novattz
85520f8916 add settings button to game cards with smokeapi installed #67 2025-12-23 01:59:53 +01:00
Novattz
ac96e7be69 smokeapi config backend implementation #67 2025-12-23 01:59:06 +01:00
Novattz
3675ff8fae add smokeapi settings dialog & styling #67 2025-12-23 01:58:30 +01:00
Novattz
ab057b8d10 add dropdown component 2025-12-23 01:57:26 +01:00
Novattz
952749cc93 fix depraction warning 2025-12-23 01:56:46 +01:00
Tickbase
4c4e087be7 Merge pull request #86 from Novattz/dependabot/npm_and_yarn/multi-ed0ec66f32
Bump glob and semantic-release
2025-12-22 22:04:41 +01:00
dependabot[bot]
1e52c2071c Bump glob and semantic-release
Bumps [glob](https://github.com/isaacs/node-glob) and [semantic-release](https://github.com/semantic-release/semantic-release). These dependencies needed to be updated together.

Updates `glob` from 11.0.2 to 11.1.0
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.2...v11.1.0)

Updates `semantic-release` from 24.2.4 to 25.0.2
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v24.2.4...v25.0.2)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
- dependency-name: semantic-release
  dependency-version: 25.0.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 21:04:04 +00:00
Tickbase
fc8c69a915 Merge pull request #85 from Novattz/dependabot/npm_and_yarn/js-yaml-4.1.1
Bump js-yaml from 4.1.0 to 4.1.1
2025-12-22 22:02:31 +01:00
dependabot[bot]
2d7077a05b Bump js-yaml from 4.1.0 to 4.1.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 20:52:35 +00:00
Tickbase
081d61afc7 Merge pull request #84 from Novattz/dependabot/npm_and_yarn/vite-6.4.1 2025-12-22 20:32:44 +01:00
dependabot[bot]
0bfd36aea9 Bump vite from 6.3.5 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 19:31:54 +00:00
20 changed files with 2121 additions and 1420 deletions

2764
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,14 +40,14 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"glob": "^11.0.2", "glob": "^11.1.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"sass-embedded": "^1.86.3", "sass-embedded": "^1.86.3",
"semantic-release": "^24.2.4", "semantic-release": "^25.0.2",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^6.3.5", "vite": "^6.4.1",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0"
} }
} }

View File

@@ -8,6 +8,7 @@ mod dlc_manager;
mod installer; mod installer;
mod searcher; mod searcher;
mod unlockers; mod unlockers;
mod smokeapi_config;
use dlc_manager::DlcInfoWithState; use dlc_manager::DlcInfoWithState;
use installer::{Game, InstallerAction, InstallerType}; 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>> { fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter; use log::LevelFilter;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
@@ -491,6 +513,9 @@ fn main() {
get_all_dlcs_command, get_all_dlcs_command,
clear_caches, clear_caches,
abort_dlc_fetch, abort_dlc_fetch,
read_smokeapi_config,
write_smokeapi_config,
delete_smokeapi_config,
]) ])
.setup(|app| { .setup(|app| {
info!("Tauri application setup"); info!("Tauri application setup");

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

View File

@@ -43,6 +43,7 @@ function App() {
settingsDialog, settingsDialog,
handleSettingsOpen, handleSettingsOpen,
handleSettingsClose, handleSettingsClose,
handleSmokeAPISettingsOpen,
} = useAppContext() } = useAppContext()
// Show update screen first // Show update screen first
@@ -86,6 +87,7 @@ function App() {
isLoading={isLoading} isLoading={isLoading}
onAction={handleGameAction} onAction={handleGameAction}
onEdit={handleGameEdit} onEdit={handleGameEdit}
onSmokeAPISettings={handleSmokeAPISettingsOpen}
/> />
)} )}
</div> </div>

View 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

View File

@@ -1,4 +1,6 @@
export { default as LoadingIndicator } from './LoadingIndicator' export { default as LoadingIndicator } from './LoadingIndicator'
export { default as ProgressBar } from './ProgressBar' export { default as ProgressBar } from './ProgressBar'
export { default as Dropdown } from './Dropdown'
export type { LoadingSize, LoadingType } from './LoadingIndicator' export type { LoadingSize, LoadingType } from './LoadingIndicator'
export type { DropdownOption } from './Dropdown'

View File

@@ -41,7 +41,7 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) =>
<Dialog visible={visible} onClose={onClose} size="medium"> <Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}> <DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="settings-header"> <div className="settings-header">
<Icon name={settings} variant="solid" size="md" /> {/*<Icon name={settings} variant="solid" size="md" />*/}
<h3>Settings</h3> <h3>Settings</h3>
</div> </div>
</DialogHeader> </DialogHeader>

View 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

View File

@@ -7,6 +7,7 @@ export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog' export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog' export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as SettingsDialog } from './SettingsDialog' export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
// Export types // Export types
export type { DialogProps } from './Dialog' export type { DialogProps } from './Dialog'

View File

@@ -8,13 +8,14 @@ interface GameItemProps {
game: Game game: Game
onAction: (gameId: string, action: ActionType) => Promise<void> onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
} }
/** /**
* Individual game card component * Individual game card component
* Displays game information and action buttons * 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 [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
@@ -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 // Determine background image
const backgroundImage = const backgroundImage =
!isLoading && imageUrl !isLoading && imageUrl
@@ -156,6 +164,20 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
iconOnly 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> </div>
</div> </div>

View File

@@ -9,13 +9,14 @@ interface GameListProps {
isLoading: boolean isLoading: boolean
onAction: (gameId: string, action: ActionType) => Promise<void> onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
} }
/** /**
* Main game list component * Main game list component
* Displays games in a grid with search and filtering applied * 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) const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title // Sort games alphabetically by title
@@ -56,7 +57,7 @@ const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
) : ( ) : (
<div className="game-grid"> <div className="game-grid">
{sortedGames.map((game) => ( {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> </div>
)} )}

View File

@@ -30,6 +30,12 @@ export interface ProgressDialogState {
instructions?: InstallationInstructions instructions?: InstallationInstructions
} }
export interface SmokeAPISettingsDialogState {
visible: boolean
gamePath: string
gameTitle: string
}
// Define the context type // Define the context type
export interface AppContextType { export interface AppContextType {
// Game state // Game state
@@ -54,6 +60,11 @@ export interface AppContextType {
handleSettingsOpen: () => void handleSettingsOpen: () => void
handleSettingsClose: () => void handleSettingsClose: () => void
// SmokeAPI settings
smokeAPISettingsDialog: SmokeAPISettingsDialogState
handleSmokeAPISettingsOpen: (gameId: string) => void
handleSmokeAPISettingsClose: () => void
// Toast notifications // Toast notifications
showToast: ( showToast: (
message: string, message: string,

View File

@@ -4,6 +4,7 @@ import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
import { DlcInfo } from '@/types' import { DlcInfo } from '@/types'
import { ActionType } from '@/components/buttons/ActionButton' import { ActionType } from '@/components/buttons/ActionButton'
import { ToastContainer } from '@/components/notifications' import { ToastContainer } from '@/components/notifications'
import { SmokeAPISettingsDialog } from '@/components/dialogs'
// Context provider component // Context provider component
interface AppProviderProps { interface AppProviderProps {
@@ -38,6 +39,17 @@ export const AppProvider = ({ children }: AppProviderProps) => {
// Settings dialog state // Settings dialog state
const [settingsDialog, setSettingsDialog] = useState({ visible: false }) 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 // Settings handlers
const handleSettingsOpen = () => { const handleSettingsOpen = () => {
setSettingsDialog({ visible: true }) setSettingsDialog({ visible: true })
@@ -47,6 +59,25 @@ export const AppProvider = ({ children }: AppProviderProps) => {
setSettingsDialog({ visible: false }) 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 // Game action handler with proper error reporting
const handleGameAction = async (gameId: string, action: ActionType) => { const handleGameAction = async (gameId: string, action: ActionType) => {
const game = games.find((g) => g.id === gameId) const game = games.find((g) => g.id === gameId)
@@ -201,6 +232,11 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleSettingsOpen, handleSettingsOpen,
handleSettingsClose, handleSettingsClose,
// SmokeAPI Settings
smokeAPISettingsDialog,
handleSmokeAPISettingsOpen,
handleSmokeAPISettingsClose,
// Toast notifications // Toast notifications
showToast, showToast,
} }
@@ -209,6 +245,14 @@ export const AppProvider = ({ children }: AppProviderProps) => {
<AppContext.Provider value={contextValue}> <AppContext.Provider value={contextValue}>
{children} {children}
<ToastContainer toasts={toasts} onDismiss={removeToast} /> <ToastContainer toasts={toasts} onDismiss={removeToast} />
{/* SmokeAPI Settings Dialog */}
<SmokeAPISettingsDialog
visible={smokeAPISettingsDialog.visible}
onClose={handleSmokeAPISettingsClose}
gamePath={smokeAPISettingsDialog.gamePath}
gameTitle={smokeAPISettingsDialog.gameTitle}
/>
</AppContext.Provider> </AppContext.Provider>
) )
} }

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

View File

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

View File

@@ -2,3 +2,4 @@
@forward './dlc_dialog'; @forward './dlc_dialog';
@forward './progress_dialog'; @forward './progress_dialog';
@forward './settings_dialog'; @forward './settings_dialog';
@forward './smokeapi_settings_dialog';

View File

@@ -18,8 +18,8 @@
.settings-section { .settings-section {
h4 { h4 {
font-size: 1.1rem; font-size: 1.2rem;
font-weight: 600; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;

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

View File

@@ -10,6 +10,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"ignoreDeprecations": "6.0",
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",