Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c8079fe19 | |||
| 993ab25bbf |
@@ -63,10 +63,17 @@ struct UiActiveOperation {
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct GamesListPayload {
|
||||
games: Vec<Game>,
|
||||
games: Vec<LauncherGame>,
|
||||
active_operations: Vec<UiActiveOperation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct LauncherGame {
|
||||
#[serde(flatten)]
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct UnpackLogEntry {
|
||||
archive: String,
|
||||
@@ -104,6 +111,17 @@ async fn get_unpack_logs(
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const GAME_START_SCRIPT: &str = "game_start.cmd";
|
||||
const SERVER_START_SCRIPT: &str = "server_start.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_LANGUAGE: &str = "en";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_USERNAME: &str = "Commander";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const MAX_USERNAME_CHARS: usize = 24;
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
@@ -381,6 +399,57 @@ fn is_single_component_game_id(id: &str) -> bool {
|
||||
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct LaunchSettings {
|
||||
language: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
||||
LaunchSettings {
|
||||
language: sanitize_language(language),
|
||||
username: sanitize_username(username),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_language(language: &str) -> String {
|
||||
match language.trim().to_ascii_lowercase().as_str() {
|
||||
"de" => "de".to_string(),
|
||||
"en" => "en".to_string(),
|
||||
_ => DEFAULT_LANGUAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_username(username: &str) -> String {
|
||||
let cleaned = username
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && *c != '"' && *c != '%')
|
||||
.take(MAX_USERNAME_CHARS)
|
||||
.collect::<String>();
|
||||
|
||||
if cleaned.is_empty() {
|
||||
DEFAULT_USERNAME.to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
format!(
|
||||
r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#,
|
||||
script_path.display(),
|
||||
id,
|
||||
settings.language,
|
||||
settings.username,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
@@ -445,8 +514,16 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_game_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring run request for invalid game id: {id}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder_lock = state.inner().games_folder.clone();
|
||||
let games_folder = {
|
||||
let guard = games_folder_lock.read().await;
|
||||
@@ -461,8 +538,8 @@ async fn run_game_windows(
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
|
||||
let game_setup_bin = game_path.join("game_setup.cmd");
|
||||
let game_start_bin = game_path.join("game_start.cmd");
|
||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
||||
|
||||
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
|
||||
if !first_start_done_file.exists() && game_setup_bin.exists() {
|
||||
@@ -476,16 +553,12 @@ async fn run_game_windows(
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_setup_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_setup_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_setup.cmd");
|
||||
log::error!("failed to run {GAME_SETUP_SCRIPT}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -500,16 +573,12 @@ async fn run_game_windows(
|
||||
if game_start_bin.exists() {
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_start_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_start.cmd");
|
||||
log::error!("failed to run {GAME_START_SCRIPT}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,21 +586,97 @@ async fn run_game_windows(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
async fn run_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_game_windows(id, state).await?;
|
||||
run_game_windows(id, language, username, state).await?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = state;
|
||||
let _ = (state, language, username);
|
||||
log::error!("run_game not implemented for this platform: id={id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn start_server_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring server start request for invalid game id: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
if !games_folder.exists() {
|
||||
log::error!("games_folder {} does not exist", games_folder.display());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
|
||||
if !server_start_bin.is_file() {
|
||||
log::warn!(
|
||||
"server start script is missing for {}: {}",
|
||||
id,
|
||||
server_start_bin.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&script_params(&server_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run {SERVER_START_SCRIPT}");
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
start_server_windows(id, language, username, state).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (state, language, username);
|
||||
log::error!("start_server not implemented for this platform: id={id}");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn local_install_is_present(game_path: &Path) -> bool {
|
||||
game_path.join("local").is_dir()
|
||||
@@ -592,6 +737,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
|
||||
let games_db_lock = state.games.clone();
|
||||
let game_db = games_db_lock.read().await;
|
||||
let games_folder = state.games_folder.read().await.clone();
|
||||
|
||||
if game_db.games.is_empty() {
|
||||
log::debug!("Game database empty; skipping emit");
|
||||
@@ -602,7 +748,11 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
.all_games()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<Game>>();
|
||||
.map(|game| LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
game,
|
||||
})
|
||||
.collect::<Vec<LauncherGame>>();
|
||||
|
||||
drop(game_db);
|
||||
|
||||
@@ -623,6 +773,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn game_can_host_server(games_folder: &str, game: &Game) -> bool {
|
||||
if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PathBuf::from(games_folder)
|
||||
.join(&game.id)
|
||||
.join(SERVER_START_SCRIPT)
|
||||
.is_file()
|
||||
}
|
||||
|
||||
fn ui_active_operations_from_map(
|
||||
active_operations: &HashMap<String, UiOperationKind>,
|
||||
) -> Vec<UiActiveOperation> {
|
||||
@@ -1440,6 +1601,68 @@ mod tests {
|
||||
assert!(!is_single_component_game_id("/game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_settings_sanitize_script_arguments() {
|
||||
assert_eq!(
|
||||
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
|
||||
LaunchSettings {
|
||||
language: "de".to_string(),
|
||||
username: "Alice AcePATH".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
launch_settings("fr", ""),
|
||||
LaunchSettings {
|
||||
language: DEFAULT_LANGUAGE.to_string(),
|
||||
username: DEFAULT_USERNAME.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_params_use_common_argument_shape() {
|
||||
let params = script_params(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(GAME_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
params,
|
||||
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_capability_requires_installed_game_with_script() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-server-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
let game_root = root.join("game");
|
||||
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
||||
std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written");
|
||||
|
||||
let mut game = game_fixture("game", "Game");
|
||||
assert!(!game_can_host_server(
|
||||
root.to_string_lossy().as_ref(),
|
||||
&game
|
||||
));
|
||||
|
||||
game.installed = true;
|
||||
assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
@@ -1506,6 +1729,7 @@ pub fn run() {
|
||||
request_games,
|
||||
install_game,
|
||||
run_game,
|
||||
start_server,
|
||||
update_game_directory,
|
||||
update_game,
|
||||
uninstall_game,
|
||||
|
||||
@@ -21,6 +21,15 @@ export const Icon = {
|
||||
<path d="M4 2.5v11l10-5.5z" />
|
||||
</svg>
|
||||
),
|
||||
server: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<rect x="2" y="3" width="12" height="4.5" rx="1" />
|
||||
<rect x="2" y="8.5" width="12" height="4.5" rx="1" />
|
||||
<circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none" />
|
||||
<path d="M7 5.25h4.5M7 10.75h4.5" />
|
||||
</svg>
|
||||
),
|
||||
install: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="M8 2v8" />
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Props {
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export const GameDetailModal = ({
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
}: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
@@ -116,6 +118,16 @@ export const GameDetailModal = ({
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ASPECT_OPTIONS,
|
||||
BG_OPTIONS,
|
||||
DENSITY_OPTIONS,
|
||||
UISettings,
|
||||
LANGUAGE_OPTIONS,
|
||||
type UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
|
||||
interface Props {
|
||||
@@ -33,6 +34,26 @@ const Row = ({ label, hint, children }: RowProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TextInputProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInputProps) => (
|
||||
<div className="settings-text">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
<div className="settings-head">
|
||||
@@ -48,6 +69,25 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Profile</div>
|
||||
<Row label="Username" hint="Shown to other players on the LAN">
|
||||
<SettingsTextInput
|
||||
value={settings.username}
|
||||
placeholder="Enter a username"
|
||||
maxLength={24}
|
||||
onChange={(v) => onChange('username', v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Language" hint="Interface language">
|
||||
<SegmentedRadio
|
||||
value={settings.language}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
onChange={(v) => onChange('language', v)}
|
||||
/>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { UseGamesResult } from './useGames';
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
|
||||
export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
@@ -19,14 +21,33 @@ export interface GameActions {
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
export const useGameActions = (
|
||||
games: UseGamesResult,
|
||||
settings: Pick<UISettings, 'language' | 'username'>,
|
||||
): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('run_game', { id });
|
||||
await invoke('run_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('run_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const startServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('start_server', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('start_server failed:', err);
|
||||
}
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -83,5 +104,5 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { GameFilter, GameSort } from '../lib/types';
|
||||
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||
|
||||
export type Density = 'compact' | 'normal' | 'large';
|
||||
@@ -15,10 +15,14 @@ export interface UISettings {
|
||||
aspect: CoverAspect;
|
||||
sort: GameSort;
|
||||
filter: GameFilter;
|
||||
username: string;
|
||||
language: LauncherLanguage;
|
||||
}
|
||||
|
||||
type StoredGameSort = GameSort | 'size';
|
||||
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
|
||||
const DEFAULT_USERNAME = 'Commander';
|
||||
const MAX_USERNAME_LENGTH = 24;
|
||||
|
||||
export const ACCENT_OPTIONS = [
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
@@ -47,6 +51,18 @@ export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }
|
||||
{ value: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
export const LANGUAGE_OPTIONS: ReadonlyArray<{ value: LauncherLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
];
|
||||
|
||||
const defaultLanguage = (): LauncherLanguage => {
|
||||
if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('de')) {
|
||||
return 'de';
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: UISettings = {
|
||||
accent: '#3b82f6',
|
||||
bg: 'gradient',
|
||||
@@ -54,6 +70,8 @@ export const DEFAULT_SETTINGS: UISettings = {
|
||||
aspect: 'square',
|
||||
sort: 'status',
|
||||
filter: 'local',
|
||||
username: DEFAULT_USERNAME,
|
||||
language: defaultLanguage(),
|
||||
};
|
||||
|
||||
const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
@@ -63,12 +81,23 @@ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
|
||||
sort: sanitizeSort(raw?.sort),
|
||||
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
|
||||
username: sanitizeUsername(raw?.username),
|
||||
language: sanitizeLanguage(raw?.language),
|
||||
});
|
||||
|
||||
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
|
||||
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort
|
||||
);
|
||||
|
||||
const sanitizeLanguage = (language: LauncherLanguage | undefined): LauncherLanguage => (
|
||||
language === 'de' || language === 'en' ? language : DEFAULT_SETTINGS.language
|
||||
);
|
||||
|
||||
const sanitizeUsername = (username: string | undefined): string => {
|
||||
const trimmed = username?.trim() ?? '';
|
||||
return trimmed ? Array.from(trimmed).slice(0, MAX_USERNAME_LENGTH).join('') : DEFAULT_USERNAME;
|
||||
};
|
||||
|
||||
export interface UseSettings {
|
||||
settings: UISettings;
|
||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Game {
|
||||
status_level?: StatusLevel;
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
@@ -78,3 +79,6 @@ export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
|
||||
/** Two-character language code passed through to game scripts. */
|
||||
export type LauncherLanguage = 'en' | 'de';
|
||||
|
||||
@@ -807,6 +807,20 @@
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--bd-3);
|
||||
}
|
||||
.act-server {
|
||||
color: var(--t-1);
|
||||
background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.act-server:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.04));
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.act-server svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.act-busy {
|
||||
color: var(--t-1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -1475,6 +1489,43 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Settings: text input */
|
||||
.settings-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.settings-text:focus-within {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.settings-text input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.settings-text input::placeholder {
|
||||
color: var(--t-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Settings: color swatches */
|
||||
.swatch-row {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
@@ -172,6 +172,7 @@ export const MainWindow = () => {
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
+75
-13
@@ -32,6 +32,14 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
||||
|
||||
---
|
||||
|
||||
## Changes since v1
|
||||
|
||||
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
|
||||
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
|
||||
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
|
||||
|
||||
---
|
||||
|
||||
## Screens / views
|
||||
|
||||
### 1. Main library (variant A — primary)
|
||||
@@ -88,12 +96,26 @@ Opens when the user **clicks anywhere on a game card except the action button**.
|
||||
2. **Body** — 22px top, 26px bottom, 28px horizontal:
|
||||
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
|
||||
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
|
||||
- **Actions row** — flex row, 10px gap, 4px top padding:
|
||||
- Primary action button (44px tall, see "Action button" below — Play / Install / Download depending on state)
|
||||
- If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
- If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
||||
- Spacer (`flex: 1`).
|
||||
- Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
||||
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
|
||||
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
|
||||
2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
|
||||
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
||||
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
|
||||
6. Spacer (`flex: 1`).
|
||||
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
||||
|
||||
#### Start Server button
|
||||
|
||||
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
|
||||
|
||||
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
|
||||
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
|
||||
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
|
||||
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
|
||||
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
|
||||
|
||||
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,10 +130,20 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ APPEARANCE │ ← section title: 10.5px / 700 / 0.12em / uppercase / --t-3
|
||||
│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
|
||||
│ │
|
||||
│ Accent color │ ← row label: 14px / 600 / --t-1
|
||||
│ Used for primary actions and highlights │ ← row hint: 12px / --t-3
|
||||
│ Username │ ← row label: 14px / 600 / --t-1
|
||||
│ Shown to other players on the LAN │ ← row hint: 12px / --t-3
|
||||
│ [ Enter a username ] │ ← text input (220×36)
|
||||
│ │
|
||||
│ Language │
|
||||
│ Interface language │
|
||||
│ [English│Deutsch] │ ← segmented radio (new)
|
||||
│ │
|
||||
│ APPEARANCE │
|
||||
│ │
|
||||
│ Accent color │
|
||||
│ Used for primary actions and highlights │
|
||||
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
|
||||
│ │
|
||||
│ Background │
|
||||
@@ -138,6 +170,11 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
|
||||
- Right (`settings-row-control`): the control
|
||||
|
||||
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
|
||||
|
||||
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
||||
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
|
||||
|
||||
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
|
||||
|
||||
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
|
||||
@@ -147,6 +184,8 @@ Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22
|
||||
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
|
||||
|
||||
Persisted settings (write through to local storage / Tauri config):
|
||||
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
|
||||
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
|
||||
- `accent`: one of the six hex values above. Default `#3b82f6`.
|
||||
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||
@@ -363,12 +402,16 @@ type Game = {
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||
speed?: number; // MB/s — present only when state === 'downloading'
|
||||
peers?: number; // LAN peers currently seeding
|
||||
players: string; // e.g. "2–32"
|
||||
tags: string[];
|
||||
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
|
||||
};
|
||||
```
|
||||
|
||||
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
|
||||
|
||||
**UI state:**
|
||||
```ts
|
||||
type LauncherUI = {
|
||||
@@ -380,7 +423,19 @@ type LauncherUI = {
|
||||
};
|
||||
```
|
||||
|
||||
**Persisted settings:** see Settings dialog section. Persist via Tauri's plugin-store or a local JSON file in app data dir.
|
||||
**Persisted settings** (mirror of Settings dialog state):
|
||||
```ts
|
||||
type LauncherSettings = {
|
||||
username: string; // new
|
||||
language: 'en' | 'de'; // new
|
||||
accent: string; // hex from the curated 6-color palette
|
||||
bg: 'flat' | 'gradient' | 'animated';
|
||||
density: 'compact' | 'normal' | 'large';
|
||||
aspect: 'box' | 'square' | 'banner';
|
||||
};
|
||||
```
|
||||
|
||||
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
|
||||
|
||||
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
|
||||
|
||||
@@ -448,7 +503,7 @@ Cover art in the design files is **stylized placeholder art** — generated enti
|
||||
|
||||
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
|
||||
|
||||
The icon set (search, play, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight.
|
||||
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
|
||||
|
||||
Fonts to load:
|
||||
```html
|
||||
@@ -475,8 +530,13 @@ design_reference/
|
||||
|
||||
To preview the design in a browser:
|
||||
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
|
||||
2. You'll see a design canvas with all variants (A, B, C, D, E) side-by-side. Click an artboard's expand button to view it full-screen.
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change accent / density / aspect / background. In the production app these live in the Settings dialog (variant E).
|
||||
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
|
||||
- **A / B** — chrome variants (A is the chosen direction)
|
||||
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
|
||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
||||
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
||||
- **F** — Settings dialog open, with the new **Profile** section at the top
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect). In the production app these live in the Settings dialog.
|
||||
|
||||
---
|
||||
|
||||
@@ -487,3 +547,5 @@ To preview the design in a browser:
|
||||
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
|
||||
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
|
||||
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
|
||||
|
||||
@@ -35,14 +35,16 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"accent": "#3b82f6",
|
||||
"density": "normal",
|
||||
"aspect": "square",
|
||||
"bg": "gradient"
|
||||
"bg": "gradient",
|
||||
"username": "d",
|
||||
"language": "en"
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
|
||||
|
||||
function App() {
|
||||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
const heroGame = GAMES.find(g => g.id === 'ra3'); // installed → modal shows Play + Uninstall
|
||||
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -62,7 +64,7 @@ function App() {
|
||||
|
||||
<DCSection id="detail" title="Game detail overlay"
|
||||
subtitle="Opens when you click a card. Full description, metadata, primary action + secondary actions (incl. uninstall).">
|
||||
<DCArtboard id="detail-modal" label="C · Detail overlay (installed game)" width={1340} height={840}>
|
||||
<DCArtboard id="detail-modal" label="C · Detail overlay (installed, can host server)" width={1340} height={840}>
|
||||
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||
initialFilter="installed" initialSort="az"
|
||||
initialOpenGame={heroGame}/>
|
||||
@@ -90,6 +92,13 @@ function App() {
|
||||
</DesignCanvas>
|
||||
|
||||
<TweaksPanel>
|
||||
<TweakSection label="Profile"/>
|
||||
<TweakText label="Username" value={t.username}
|
||||
onChange={(v) => setTweak('username', v)}/>
|
||||
<TweakRadio label="Language" value={t.language}
|
||||
options={[{value: 'en', label: 'English'}, {value: 'de', label: 'Deutsch'}]}
|
||||
onChange={(v) => setTweak('language', v)}/>
|
||||
|
||||
<TweakSection label="Theme"/>
|
||||
<TweakColor label="Accent" value={t.accent} options={ACCENTS}
|
||||
onChange={(v) => setTweak('accent', v)}/>
|
||||
|
||||
@@ -9,6 +9,7 @@ const { useState, useMemo, useRef, useEffect } = React;
|
||||
const Icon = {
|
||||
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
|
||||
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
|
||||
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
|
||||
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
|
||||
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
|
||||
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
|
||||
@@ -435,6 +436,13 @@ function GameDetailModal({ game, accent, onClose }) {
|
||||
<p className="modal-desc">{game.desc}</p>
|
||||
<div className="modal-actions">
|
||||
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
|
||||
{game.canHostServer && game.state === 'installed' && (
|
||||
<button className="act-btn act-lg act-server"
|
||||
style={{ '--accent': accent }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Icon.server/><span>Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.state === 'installed' && (
|
||||
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
|
||||
)}
|
||||
@@ -468,8 +476,22 @@ const SETTING_OPTIONS = {
|
||||
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
|
||||
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
|
||||
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
|
||||
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
|
||||
};
|
||||
|
||||
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
|
||||
return (
|
||||
<div className="settings-text" style={{ '--accent': accent }}>
|
||||
<input type="text"
|
||||
value={value || ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({ label, hint, children }) {
|
||||
return (
|
||||
<div className="settings-row">
|
||||
@@ -524,6 +546,21 @@ function SettingsDialog({ settings, onChange, onClose }) {
|
||||
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
|
||||
</div>
|
||||
<div className="settings-body">
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-title">Profile</div>
|
||||
<SettingsRow label="Username" hint="Shown to other players on the LAN">
|
||||
<SettingsTextInput value={settings.username}
|
||||
placeholder="Enter a username"
|
||||
onChange={(v) => onChange('username', v)}
|
||||
accent={settings.accent}/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Language" hint="Interface language">
|
||||
<SegmentedRadio value={settings.language || 'en'}
|
||||
options={SETTING_OPTIONS.language}
|
||||
onChange={(v) => onChange('language', v)}
|
||||
accent={settings.accent}/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
|
||||
|
||||
@@ -28,13 +28,13 @@ const GAMES = [
|
||||
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' },
|
||||
},
|
||||
{
|
||||
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30',
|
||||
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30', canHostServer: true,
|
||||
desc: "The original Battlefield. WWII on land, sea, and air across 16 maps. The mod scene basically reinvented PC gaming on top of this engine.",
|
||||
state: 'installed', players: '2–64', tags: ['FPS', 'Vehicles', 'LAN'],
|
||||
cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27',
|
||||
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27', canHostServer: true,
|
||||
desc: "Modern combat with commander mode, squads, and the kind of jet-vs-jet duels you tell stories about for a decade.",
|
||||
state: 'local', players: '2–64', tags: ['FPS', 'Vehicles', 'Tactical'],
|
||||
cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: 'tactical' },
|
||||
@@ -46,19 +46,19 @@ const GAMES = [
|
||||
cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' },
|
||||
},
|
||||
{
|
||||
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22',
|
||||
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22', canHostServer: true,
|
||||
desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'War'],
|
||||
cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21',
|
||||
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21', canHostServer: true,
|
||||
desc: "The shooter that flipped the genre to modern combat and minted a generation of esports careers. All Ghillied Up still holds up.",
|
||||
state: 'local', players: '2–32', tags: ['FPS', 'Modern'],
|
||||
cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08',
|
||||
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08', canHostServer: true,
|
||||
desc: "Expansion to the original CoD. Battle of the Bulge, Sicily, Kursk. Adds tanks, B-17 sequences, and the flamethrower nobody asked for but everybody loved.",
|
||||
state: 'none', players: '2–32', tags: ['FPS', 'Expansion'],
|
||||
cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' },
|
||||
@@ -76,37 +76,37 @@ const GAMES = [
|
||||
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' },
|
||||
},
|
||||
{
|
||||
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21',
|
||||
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21', canHostServer: true,
|
||||
desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'Competitive', 'LAN'],
|
||||
cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23',
|
||||
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23', canHostServer: true,
|
||||
desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.",
|
||||
state: 'installed', players: '2–32', tags: ['FPS', 'Competitive'],
|
||||
cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' },
|
||||
},
|
||||
{
|
||||
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20',
|
||||
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20', canHostServer: true,
|
||||
desc: "Open-source arena FPS with in-game level editing. Fast, free, and one of those things every LAN party always had a copy of.",
|
||||
state: 'none', players: '2–16', tags: ['FPS', 'Open Source'],
|
||||
cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' },
|
||||
},
|
||||
{
|
||||
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31',
|
||||
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31', canHostServer: true,
|
||||
desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.",
|
||||
state: 'local', players: '2–16', tags: ['FPS', 'Horror'],
|
||||
cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' },
|
||||
},
|
||||
{
|
||||
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15',
|
||||
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15', canHostServer: true,
|
||||
desc: "Co-op zombie survival with the AI Director rewriting every campaign run. Bring four friends or four strangers; the chainsaw works the same.",
|
||||
state: 'installed', players: '1–8', tags: ['Co-op', 'FPS', 'Horror'],
|
||||
cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: 'horror' },
|
||||
},
|
||||
{
|
||||
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01',
|
||||
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01', canHostServer: true,
|
||||
desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.",
|
||||
state: 'installed', players: '2–100', tags: ['Sandbox', 'Survival', 'LAN'],
|
||||
cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' },
|
||||
@@ -118,7 +118,7 @@ const GAMES = [
|
||||
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' },
|
||||
},
|
||||
{
|
||||
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
|
||||
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15', canHostServer: true,
|
||||
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
|
||||
state: 'downloading', progress: 0.71, speed: 12.8, peers: 3, players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
|
||||
@@ -130,13 +130,13 @@ const GAMES = [
|
||||
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' },
|
||||
},
|
||||
{
|
||||
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18',
|
||||
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18', canHostServer: true,
|
||||
desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.",
|
||||
state: 'local', players: '2–32', tags: ['FPS', 'Class-based'],
|
||||
cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' },
|
||||
},
|
||||
{
|
||||
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01',
|
||||
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01', canHostServer: true,
|
||||
desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.",
|
||||
state: 'none', players: '2–32', tags: ['FPS', 'Arena'],
|
||||
cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' },
|
||||
|
||||
@@ -630,6 +630,22 @@
|
||||
}
|
||||
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
|
||||
|
||||
/* Start Server — secondary "primary" action sitting next to Play.
|
||||
Uses the accent as a tinted fill + border so it reads as host-action
|
||||
without competing with the green Play button. */
|
||||
.act-server {
|
||||
color: var(--t-1);
|
||||
background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
}
|
||||
.act-server:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.04));
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.act-server svg { color: var(--accent); }
|
||||
|
||||
/* ─── Download progress (in place of action button when state === 'downloading') ─── */
|
||||
.dl {
|
||||
position: relative;
|
||||
@@ -1135,3 +1151,35 @@
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18);
|
||||
}
|
||||
|
||||
/* ─── Settings: text input ─── */
|
||||
.settings-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 220px;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
.settings-text:focus-within {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--accent, #3b82f6);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent, #3b82f6) 22%, transparent);
|
||||
}
|
||||
.settings-text input {
|
||||
flex: 1; min-width: 0;
|
||||
background: transparent;
|
||||
border: 0; outline: 0;
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.settings-text input::placeholder {
|
||||
color: var(--t-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user