Compare commits

...

2 Commits

Author SHA1 Message Date
ddidderr 8c8079fe19 feat: pass profile settings to launch scripts
Add launcher profile settings for username and language, then thread those
values into the Windows script launch path. The game setup, game start, and
server start scripts now share the same argument shape:

- game path: local
- game id
- language: en or de
- player name

Expose a local can_host_server flag in the games payload by checking for
server_start.cmd in an installed game's root directory. The detail modal uses
that flag to show Start Server only for installed games with the script, and
the new start_server command invokes server_start.cmd with the same sanitized
settings used by game_setup.cmd and game_start.cmd.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check

Refs: design/README.md
2026-05-21 09:40:23 +02:00
ddidderr 993ab25bbf docs: update launcher design for profile and server actions
Document the profile settings added to the launcher design and the new
Start Server detail action. The settings contract now includes a persisted
username and language choice, and the game detail overlay shows Start Server
only for installed games that can host a dedicated server.

The reference mock now includes the matching Profile controls, a server icon,
server-capable sample catalog entries, and the updated detail/settings
artboards so implementation can follow the selected design direction.

Test Plan:
- git diff --cached --check

Refs: design/README.md
2026-05-21 09:23:05 +02:00
14 changed files with 604 additions and 57 deletions
@@ -63,10 +63,17 @@ struct UiActiveOperation {
#[derive(Clone, Debug, serde::Serialize)] #[derive(Clone, Debug, serde::Serialize)]
struct GamesListPayload { struct GamesListPayload {
games: Vec<Game>, games: Vec<LauncherGame>,
active_operations: Vec<UiActiveOperation>, active_operations: Vec<UiActiveOperation>,
} }
#[derive(Clone, Debug, serde::Serialize)]
struct LauncherGame {
#[serde(flatten)]
game: Game,
can_host_server: bool,
}
#[derive(Clone, Debug, serde::Serialize)] #[derive(Clone, Debug, serde::Serialize)]
struct UnpackLogEntry { struct UnpackLogEntry {
archive: String, archive: String,
@@ -104,6 +111,17 @@ async fn get_unpack_logs(
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; 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] #[tauri::command]
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { 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() 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] #[tauri::command]
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> { async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
let peer_ctrl_arc = state.inner().peer_ctrl.clone(); 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")] #[cfg(target_os = "windows")]
async fn run_game_windows( async fn run_game_windows(
id: String, id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>, state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<()> { ) -> 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_lock = state.inner().games_folder.clone();
let games_folder = { let games_folder = {
let guard = games_folder_lock.read().await; 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_path = games_folder.join(id.clone());
let game_setup_bin = game_path.join("game_setup.cmd"); let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
let game_start_bin = game_path.join("game_start.cmd"); let game_start_bin = game_path.join(GAME_START_SCRIPT);
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE); let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
if !first_start_done_file.exists() && game_setup_bin.exists() { if !first_start_done_file.exists() && game_setup_bin.exists() {
@@ -476,16 +553,12 @@ async fn run_game_windows(
let result = run_as_admin( let result = run_as_admin(
"cmd.exe", "cmd.exe",
&format!( &script_params(&game_setup_bin, &id, &settings),
r#"/c "{} local {} de playername""#,
game_setup_bin.display(),
&id
),
&game_path.display().to_string(), &game_path.display().to_string(),
); );
if !result { if !result {
log::error!("failed to run game_setup.cmd"); log::error!("failed to run {GAME_SETUP_SCRIPT}");
return Ok(()); return Ok(());
} }
@@ -500,16 +573,12 @@ async fn run_game_windows(
if game_start_bin.exists() { if game_start_bin.exists() {
let result = run_as_admin( let result = run_as_admin(
"cmd.exe", "cmd.exe",
&format!( &script_params(&game_start_bin, &id, &settings),
r#"/c "{} local {} de playername""#,
game_start_bin.display(),
&id
),
&game_path.display().to_string(), &game_path.display().to_string(),
); );
if !result { 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] #[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")] #[cfg(target_os = "windows")]
{ {
run_game_windows(id, state).await?; run_game_windows(id, language, username, state).await?;
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let _ = state; let _ = (state, language, username);
log::error!("run_game not implemented for this platform: id={id}"); log::error!("run_game not implemented for this platform: id={id}");
} }
Ok(()) 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")] #[cfg(target_os = "windows")]
fn local_install_is_present(game_path: &Path) -> bool { fn local_install_is_present(game_path: &Path) -> bool {
game_path.join("local").is_dir() 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 games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await; let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone();
if game_db.games.is_empty() { if game_db.games.is_empty() {
log::debug!("Game database empty; skipping emit"); log::debug!("Game database empty; skipping emit");
@@ -602,7 +748,11 @@ async fn emit_games_list(app_handle: &AppHandle) {
.all_games() .all_games()
.into_iter() .into_iter()
.cloned() .cloned()
.collect::<Vec<Game>>(); .map(|game| LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
game,
})
.collect::<Vec<LauncherGame>>();
drop(game_db); 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( fn ui_active_operations_from_map(
active_operations: &HashMap<String, UiOperationKind>, active_operations: &HashMap<String, UiOperationKind>,
) -> Vec<UiActiveOperation> { ) -> Vec<UiActiveOperation> {
@@ -1440,6 +1601,68 @@ mod tests {
assert!(!is_single_component_game_id("/game")); 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] #[test]
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() { fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
let mut alpha = game_fixture("alpha", "Catalog Alpha"); let mut alpha = game_fixture("alpha", "Catalog Alpha");
@@ -1506,6 +1729,7 @@ pub fn run() {
request_games, request_games,
install_game, install_game,
run_game, run_game,
start_server,
update_game_directory, update_game_directory,
update_game, update_game,
uninstall_game, uninstall_game,
@@ -21,6 +21,15 @@ export const Icon = {
<path d="M4 2.5v11l10-5.5z" /> <path d="M4 2.5v11l10-5.5z" />
</svg> </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) => ( install: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}> <svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" /> <path d="M8 2v8" />
@@ -16,6 +16,7 @@ interface Props {
onUninstall: (game: Game) => void; onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void; onRemoveDownload: (game: Game) => void;
onCancelDownload: (game: Game) => void; onCancelDownload: (game: Game) => void;
onStartServer: (game: Game) => void;
onViewFiles: (game: Game) => void; onViewFiles: (game: Game) => void;
} }
@@ -45,6 +46,7 @@ export const GameDetailModal = ({
onUninstall, onUninstall,
onRemoveDownload, onRemoveDownload,
onCancelDownload, onCancelDownload,
onStartServer,
onViewFiles, onViewFiles,
}: Props) => { }: Props) => {
const tags = tagsFromGame(game); const tags = tagsFromGame(game);
@@ -116,6 +118,16 @@ export const GameDetailModal = ({
onClick={() => onPrimary(game)} onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload} 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 && ( {game.installed && (
<button <button
type="button" type="button"
@@ -8,7 +8,8 @@ import {
ASPECT_OPTIONS, ASPECT_OPTIONS,
BG_OPTIONS, BG_OPTIONS,
DENSITY_OPTIONS, DENSITY_OPTIONS,
UISettings, LANGUAGE_OPTIONS,
type UISettings,
} from '../../hooks/useSettings'; } from '../../hooks/useSettings';
interface Props { interface Props {
@@ -33,6 +34,26 @@ const Row = ({ label, hint, children }: RowProps) => (
</div> </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) => ( export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
<Modal onClose={onClose} className="settings-modal"> <Modal onClose={onClose} className="settings-modal">
<div className="settings-head"> <div className="settings-head">
@@ -48,6 +69,25 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
</div> </div>
<div className="settings-body"> <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"> <section className="settings-section">
<div className="settings-section-title">Appearance</div> <div className="settings-section-title">Appearance</div>
<Row label="Accent color" hint="Used for primary actions and highlights"> <Row label="Accent color" hint="Used for primary actions and highlights">
@@ -1,10 +1,12 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { UseGamesResult } from './useGames'; import { type UseGamesResult } from './useGames';
import { type UISettings } from './useSettings';
export interface GameActions { export interface GameActions {
play: (id: string) => Promise<void>; play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>; install: (id: string) => Promise<void>;
update: (id: string) => Promise<void>; update: (id: string) => Promise<void>;
uninstall: (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 * are marked as "checking peers" until the backend emits an authoritative
* operation snapshot; cancellation waits for the backend to clear that snapshot. * 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) => { const play = useCallback(async (id: string) => {
try { try {
await invoke('run_game', { id }); await invoke('run_game', {
id,
language: settings.language,
username: settings.username,
});
} catch (err) { } catch (err) {
console.error('run_game failed:', 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) => { const install = useCallback(async (id: string) => {
try { 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 { useCallback, useEffect, useState } from 'react';
import { load } from '@tauri-apps/plugin-store'; 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'; import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
export type Density = 'compact' | 'normal' | 'large'; export type Density = 'compact' | 'normal' | 'large';
@@ -15,10 +15,14 @@ export interface UISettings {
aspect: CoverAspect; aspect: CoverAspect;
sort: GameSort; sort: GameSort;
filter: GameFilter; filter: GameFilter;
username: string;
language: LauncherLanguage;
} }
type StoredGameSort = GameSort | 'size'; type StoredGameSort = GameSort | 'size';
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>; type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
const DEFAULT_USERNAME = 'Commander';
const MAX_USERNAME_LENGTH = 24;
export const ACCENT_OPTIONS = [ export const ACCENT_OPTIONS = [
{ value: '#3b82f6', label: 'Blue' }, { value: '#3b82f6', label: 'Blue' },
@@ -47,6 +51,18 @@ export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }
{ value: 'banner', label: 'Banner' }, { 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 = { export const DEFAULT_SETTINGS: UISettings = {
accent: '#3b82f6', accent: '#3b82f6',
bg: 'gradient', bg: 'gradient',
@@ -54,6 +70,8 @@ export const DEFAULT_SETTINGS: UISettings = {
aspect: 'square', aspect: 'square',
sort: 'status', sort: 'status',
filter: 'local', filter: 'local',
username: DEFAULT_USERNAME,
language: defaultLanguage(),
}; };
const sanitize = (raw: StoredUISettings | undefined): UISettings => ({ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
@@ -63,12 +81,23 @@ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect, aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
sort: sanitizeSort(raw?.sort), sort: sanitizeSort(raw?.sort),
filter: raw?.filter ?? DEFAULT_SETTINGS.filter, filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
username: sanitizeUsername(raw?.username),
language: sanitizeLanguage(raw?.language),
}); });
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => ( const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort 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 { export interface UseSettings {
settings: UISettings; settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void; set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
@@ -58,6 +58,7 @@ export interface Game {
status_level?: StatusLevel; status_level?: StatusLevel;
download_progress?: DownloadProgress; download_progress?: DownloadProgress;
peer_count: number; peer_count: number;
can_host_server?: boolean;
} }
export interface ActiveOperation { 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. */ /** Visual state of a card. Derived from backend operation status and local flags. */
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy'; 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); background: rgba(255, 255, 255, 0.12);
border-color: var(--bd-3); 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 { .act-busy {
color: var(--t-1); color: var(--t-1);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
@@ -1475,6 +1489,43 @@
filter: brightness(1.1); 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 */ /* Settings: color swatches */
.swatch-row { .swatch-row {
display: inline-flex; display: inline-flex;
@@ -47,7 +47,7 @@ export const MainWindow = () => {
const { settings, set: setSetting } = useSettings(); const { settings, set: setSetting } = useSettings();
const { gameDir, setGameDir, rescan } = useGameDirectory(); const { gameDir, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan); const games = useGames(rescan);
const actions = useGameActions(games); const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(); const thumbnails = useThumbnails();
const [openGameId, setOpenGameId] = useState<string | null>(null); const [openGameId, setOpenGameId] = useState<string | null>(null);
@@ -172,6 +172,7 @@ export const MainWindow = () => {
onUninstall={handleUninstall} onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload} onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)} onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)} onViewFiles={(g) => actions.viewFiles(g.id)}
/> />
)} )}
+75 -13
View File
@@ -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 ## Screens / views
### 1. Main library (variant A — primary) ### 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: 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). - **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`. - **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding: - **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
- Primary action button (44px tall, see "Action button" below — Play / Install / Download depending on state) 1. **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`. 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).
- If `state === 'local'`: ghost-button **Delete from disk** (same danger styling). 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`.
- Spacer (`flex: 1`). 4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
- Ghost-button **View files** (neutral) — opens system file manager at the game folder. 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 │ 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 Username │ ← row label: 14px / 600 / --t-1
Used for primary actions and highlights │ ← row hint: 12px / --t-3 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 │ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │ │ │
│ Background │ │ 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`) - Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control - 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)`. **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`. 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. **Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config): 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`. - `accent`: one of the six hex values above. Default `#3b82f6`.
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`. - `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`. - `density`: `compact` | `normal` | `large`. Default `normal`.
@@ -363,12 +402,16 @@ type Game = {
state: 'installed' | 'local' | 'downloading' | 'none'; state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading' progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading' speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232" players: string; // e.g. "232"
tags: string[]; tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: 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:** **UI state:**
```ts ```ts
type LauncherUI = { 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. **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`. 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: Fonts to load:
```html ```html
@@ -475,8 +530,13 @@ design_reference/
To preview the design in a browser: 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). 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. 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.
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). - **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. - **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. - **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. - **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.
+12 -3
View File
@@ -35,14 +35,16 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6", "accent": "#3b82f6",
"density": "normal", "density": "normal",
"aspect": "square", "aspect": "square",
"bg": "gradient" "bg": "gradient",
"username": "d",
"language": "en"
}/*EDITMODE-END*/; }/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444']; const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
function App() { function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); 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 ( return (
<React.Fragment> <React.Fragment>
@@ -62,7 +64,7 @@ function App() {
<DCSection id="detail" title="Game detail overlay" <DCSection id="detail" title="Game detail overlay"
subtitle="Opens when you click a card. Full description, metadata, primary action + secondary actions (incl. uninstall)."> 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} <Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="installed" initialSort="az" initialFilter="installed" initialSort="az"
initialOpenGame={heroGame}/> initialOpenGame={heroGame}/>
@@ -90,6 +92,13 @@ function App() {
</DesignCanvas> </DesignCanvas>
<TweaksPanel> <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"/> <TweakSection label="Theme"/>
<TweakColor label="Accent" value={t.accent} options={ACCENTS} <TweakColor label="Accent" value={t.accent} options={ACCENTS}
onChange={(v) => setTweak('accent', v)}/> onChange={(v) => setTweak('accent', v)}/>
+37
View File
@@ -9,6 +9,7 @@ const { useState, useMemo, useRef, useEffect } = React;
const Icon = { 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>, 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>, 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>, 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>, 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>, 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> <p className="modal-desc">{game.desc}</p>
<div className="modal-actions"> <div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg" game={game}/> <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' && ( {game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button> <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' }], 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' }], 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' }], 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 }) { function SettingsRow({ label, hint, children }) {
return ( return (
<div className="settings-row"> <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> <button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
</div> </div>
<div className="settings-body"> <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">
<div className="settings-section-title">Appearance</div> <div className="settings-section-title">Appearance</div>
<SettingsRow label="Accent color" hint="Used for primary actions and highlights"> <SettingsRow label="Accent color" hint="Used for primary actions and highlights">
+14 -14
View File
@@ -28,13 +28,13 @@ const GAMES = [
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' }, 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.", 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: '264', tags: ['FPS', 'Vehicles', 'LAN'], state: 'installed', players: '264', tags: ['FPS', 'Vehicles', 'LAN'],
cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' }, 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.", 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: '264', tags: ['FPS', 'Vehicles', 'Tactical'], state: 'local', players: '264', tags: ['FPS', 'Vehicles', 'Tactical'],
cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: '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' }, 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.", desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.",
state: 'installed', players: '232', tags: ['FPS', 'War'], state: 'installed', players: '232', tags: ['FPS', 'War'],
cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: '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.", 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: '232', tags: ['FPS', 'Modern'], state: 'local', players: '232', tags: ['FPS', 'Modern'],
cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' }, 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.", 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: '232', tags: ['FPS', 'Expansion'], state: 'none', players: '232', tags: ['FPS', 'Expansion'],
cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' }, cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' },
@@ -76,37 +76,37 @@ const GAMES = [
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' }, 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.", desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive', 'LAN'], state: 'installed', players: '232', tags: ['FPS', 'Competitive', 'LAN'],
cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' }, 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.", desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive'], state: 'installed', players: '232', tags: ['FPS', 'Competitive'],
cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' }, 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.", 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: '216', tags: ['FPS', 'Open Source'], state: 'none', players: '216', tags: ['FPS', 'Open Source'],
cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' }, 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.", desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.",
state: 'local', players: '216', tags: ['FPS', 'Horror'], state: 'local', players: '216', tags: ['FPS', 'Horror'],
cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' }, 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.", 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: '18', tags: ['Co-op', 'FPS', 'Horror'], state: 'installed', players: '18', tags: ['Co-op', 'FPS', 'Horror'],
cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: '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.", desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.",
state: 'installed', players: '2100', tags: ['Sandbox', 'Survival', 'LAN'], state: 'installed', players: '2100', tags: ['Sandbox', 'Survival', 'LAN'],
cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' }, cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' },
@@ -118,7 +118,7 @@ const GAMES = [
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' }, 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.", 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: '216', tags: ['FPS', 'Arena', 'LAN'], state: 'downloading', progress: 0.71, speed: 12.8, peers: 3, players: '216', tags: ['FPS', 'Arena', 'LAN'],
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' }, cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
@@ -130,13 +130,13 @@ const GAMES = [
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' }, 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.", desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.",
state: 'local', players: '232', tags: ['FPS', 'Class-based'], state: 'local', players: '232', tags: ['FPS', 'Class-based'],
cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' }, 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.", desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.",
state: 'none', players: '232', tags: ['FPS', 'Arena'], state: 'none', players: '232', tags: ['FPS', 'Arena'],
cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' }, cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' },
+48
View File
@@ -630,6 +630,22 @@
} }
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); } .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') ─── */ /* ─── Download progress (in place of action button when state === 'downloading') ─── */
.dl { .dl {
position: relative; position: relative;
@@ -1135,3 +1151,35 @@
color: white; color: white;
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18); 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;
}