Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
640214ec38
|
|||
|
27c71978d2
|
|||
|
ff35f0d95f
|
|||
|
b35755f4e6
|
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
|
||||||
|
<title>SoftLAN Launcher</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"store:default"
|
"store:default"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "unpack-logs",
|
||||||
|
"description": "Capability for the unpack-logs window",
|
||||||
|
"windows": [
|
||||||
|
"unpack-logs"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use std::{
|
|||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use eyre::bail;
|
use eyre::bail;
|
||||||
@@ -40,6 +41,7 @@ struct LanSpreadState {
|
|||||||
games_folder: Arc<RwLock<String>>,
|
games_folder: Arc<RwLock<String>>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||||
@@ -64,19 +66,41 @@ struct GamesListPayload {
|
|||||||
active_operations: Vec<UiActiveOperation>,
|
active_operations: Vec<UiActiveOperation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
|
struct UnpackLogEntry {
|
||||||
|
archive: String,
|
||||||
|
destination: String,
|
||||||
|
status_code: Option<i32>,
|
||||||
|
stdout: String,
|
||||||
|
stderr: String,
|
||||||
|
started_at_ms: u64,
|
||||||
|
finished_at_ms: u64,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
struct SidecarUnpacker {
|
struct SidecarUnpacker {
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_UNPACK_LOGS: usize = 100;
|
||||||
|
|
||||||
impl Unpacker for SidecarUnpacker {
|
impl Unpacker for SidecarUnpacker {
|
||||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let sidecar = self.app_handle.shell().sidecar("unrar")?;
|
let app_handle = self.app_handle.clone();
|
||||||
do_unrar(sidecar, archive, dest).await
|
let sidecar = app_handle.shell().sidecar("unrar")?;
|
||||||
|
do_unrar(&app_handle, sidecar, archive, dest).await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_unpack_logs(
|
||||||
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
|
) -> tauri::Result<Vec<UnpackLogEntry>> {
|
||||||
|
Ok(state.inner().unpack_logs.read().await.clone())
|
||||||
|
}
|
||||||
|
|
||||||
#[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";
|
||||||
|
|
||||||
@@ -596,47 +620,216 @@ fn add_final_slash(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::Result<()> {
|
async fn do_unrar(
|
||||||
if let Ok(()) = std::fs::create_dir_all(dest_dir) {
|
app_handle: &AppHandle,
|
||||||
if let Ok(rar_file) = rar_file.canonicalize() {
|
sidecar: Command,
|
||||||
if let Ok(dest_dir) = dest_dir.canonicalize() {
|
rar_file: &Path,
|
||||||
let dest_dir = dest_dir
|
dest_dir: &Path,
|
||||||
.to_str()
|
) -> eyre::Result<()> {
|
||||||
.ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?;
|
let started_at_ms = now_millis();
|
||||||
|
let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"unrar game: {} to {}",
|
"unrar game: {} to {}",
|
||||||
rar_file.canonicalize()?.display(),
|
paths.archive.display(),
|
||||||
dest_dir
|
paths.destination.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let out = sidecar
|
run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnrarPaths {
|
||||||
|
archive: PathBuf,
|
||||||
|
destination: PathBuf,
|
||||||
|
destination_arg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_unrar_paths(
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
rar_file: &Path,
|
||||||
|
dest_dir: &Path,
|
||||||
|
started_at_ms: u64,
|
||||||
|
) -> eyre::Result<UnrarPaths> {
|
||||||
|
let original_archive = rar_file.display().to_string();
|
||||||
|
let original_destination = dest_dir.display().to_string();
|
||||||
|
|
||||||
|
if let Err(err) = std::fs::create_dir_all(dest_dir) {
|
||||||
|
let stderr = format!("failed to create directory {}: {err}", dest_dir.display());
|
||||||
|
record_unpack_failure(
|
||||||
|
app_handle,
|
||||||
|
original_archive,
|
||||||
|
original_destination,
|
||||||
|
started_at_ms,
|
||||||
|
stderr.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
bail!("{stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rar_file = match rar_file.canonicalize() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
let stderr = format!(
|
||||||
|
"rar_file canonicalize failed for {}: {err}",
|
||||||
|
rar_file.display()
|
||||||
|
);
|
||||||
|
record_unpack_failure(
|
||||||
|
app_handle,
|
||||||
|
original_archive,
|
||||||
|
original_destination,
|
||||||
|
started_at_ms,
|
||||||
|
stderr.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
bail!("{stderr}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dest_dir = match dest_dir.canonicalize() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
let stderr = format!(
|
||||||
|
"dest_dir canonicalize failed for {}: {err}",
|
||||||
|
dest_dir.display()
|
||||||
|
);
|
||||||
|
record_unpack_failure(
|
||||||
|
app_handle,
|
||||||
|
rar_file.display().to_string(),
|
||||||
|
original_destination,
|
||||||
|
started_at_ms,
|
||||||
|
stderr.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
bail!("{stderr}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(dest_dir_arg) = dest_dir.to_str().map(add_final_slash) else {
|
||||||
|
let stderr = format!("failed to get str of dest_dir {}", dest_dir.display());
|
||||||
|
record_unpack_failure(
|
||||||
|
app_handle,
|
||||||
|
rar_file.display().to_string(),
|
||||||
|
dest_dir.display().to_string(),
|
||||||
|
started_at_ms,
|
||||||
|
stderr.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
bail!("{stderr}");
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UnrarPaths {
|
||||||
|
archive: rar_file,
|
||||||
|
destination: dest_dir,
|
||||||
|
destination_arg: dest_dir_arg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_unrar_sidecar(
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
sidecar: Command,
|
||||||
|
paths: &UnrarPaths,
|
||||||
|
started_at_ms: u64,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
let out = match sidecar
|
||||||
.arg("x") // extract files
|
.arg("x") // extract files
|
||||||
.arg(rar_file.canonicalize()?)
|
.arg(&paths.archive)
|
||||||
.arg("-y") // Assume Yes on all queries
|
.arg("-y") // Assume Yes on all queries
|
||||||
.arg("-o") // Set overwrite mode
|
.arg("-o") // Set overwrite mode
|
||||||
.arg(add_final_slash(dest_dir))
|
.arg(&paths.destination_arg)
|
||||||
.output()
|
.output()
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) => out,
|
||||||
|
Err(err) => {
|
||||||
|
let stderr = format!("failed to run unrar sidecar: {err}");
|
||||||
|
record_unpack_failure(
|
||||||
|
app_handle,
|
||||||
|
paths.archive.display().to_string(),
|
||||||
|
paths.destination.display().to_string(),
|
||||||
|
started_at_ms,
|
||||||
|
stderr.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
bail!("{stderr}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !out.status.success() {
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||||
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||||
bail!(
|
let status_code = out.status.code();
|
||||||
"unrar failed with status {:?}: {}",
|
let success = out.status.success();
|
||||||
out.status.code(),
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
record_unpack_log(
|
||||||
);
|
app_handle,
|
||||||
|
UnpackLogEntry {
|
||||||
|
archive: paths.archive.display().to_string(),
|
||||||
|
destination: paths.destination.display().to_string(),
|
||||||
|
status_code,
|
||||||
|
stdout: stdout.clone(),
|
||||||
|
stderr: stderr.clone(),
|
||||||
|
started_at_ms,
|
||||||
|
finished_at_ms: now_millis(),
|
||||||
|
success,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
if !stdout.trim().is_empty() {
|
||||||
|
log::error!("unrar stdout: {stdout}");
|
||||||
|
}
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
log::error!("unrar stderr: {stderr}");
|
||||||
|
}
|
||||||
|
bail!("unrar failed with status {status_code:?}: {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
log::error!("dest_dir canonicalize failed: {}", dest_dir.display());
|
|
||||||
} else {
|
async fn record_unpack_failure(
|
||||||
log::error!("rar_file canonicalize failed: {}", rar_file.display());
|
app_handle: &AppHandle,
|
||||||
|
archive: String,
|
||||||
|
destination: String,
|
||||||
|
started_at_ms: u64,
|
||||||
|
stderr: String,
|
||||||
|
) {
|
||||||
|
record_unpack_log(
|
||||||
|
app_handle,
|
||||||
|
UnpackLogEntry {
|
||||||
|
archive,
|
||||||
|
destination,
|
||||||
|
status_code: None,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr,
|
||||||
|
started_at_ms,
|
||||||
|
finished_at_ms: now_millis(),
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
||||||
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
|
{
|
||||||
|
let mut logs = state.inner().unpack_logs.write().await;
|
||||||
|
logs.push(entry);
|
||||||
|
if logs.len() > MAX_UNPACK_LOGS {
|
||||||
|
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
||||||
|
logs.drain(..overflow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bail!("failed to create directory: {dest_dir:?}");
|
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
||||||
|
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| {
|
||||||
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the bundled catalog database packaged with the Tauri application.
|
/// Resolve the bundled catalog database packaged with the Tauri application.
|
||||||
@@ -1123,7 +1316,8 @@ pub fn run() {
|
|||||||
update_game,
|
update_game,
|
||||||
uninstall_game,
|
uninstall_game,
|
||||||
get_peer_count,
|
get_peer_count,
|
||||||
get_game_thumbnail
|
get_game_thumbnail,
|
||||||
|
get_unpack_logs
|
||||||
])
|
])
|
||||||
.manage(LanSpreadState::default())
|
.manage(LanSpreadState::default())
|
||||||
.manage(PeerEventTx(tx_peer_event))
|
.manage(PeerEventTx(tx_peer_event))
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: #000313;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
color: #D5DBFE;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background-color: #000313;
|
|
||||||
z-index: 1000;
|
|
||||||
padding-top: 20px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1.align-center {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-header {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-container {
|
|
||||||
margin-top: 160px; /* Adjust based on your header height */
|
|
||||||
padding: 20px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: linear-gradient(to bottom, black, #000938);
|
|
||||||
color: white;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background 0.3s;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
/* max-width: 280px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover {
|
|
||||||
background: linear-gradient(to bottom, black, #3849AB);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item img {
|
|
||||||
width: 280px; /* Fixed width */
|
|
||||||
height: 200px; /* Fixed height */
|
|
||||||
object-fit: cover;
|
|
||||||
display: block; /* Removes any unwanted spacing */
|
|
||||||
margin: 0 auto; /* Centers the image if container is wider */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px 0;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 10px 10px 10px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badges {
|
|
||||||
display: flex;
|
|
||||||
min-height: 24px;
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
border: 1px solid #4866b9;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #D5DBFE;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 5px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.local-only {
|
|
||||||
border-color: #8b6f2a;
|
|
||||||
color: #f1d58a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc-text {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.size-text {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
padding: 15px 30px;
|
|
||||||
background: linear-gradient(45deg, #09305a, #37529c);
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 8px 15px rgba(0, 191, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button:hover {
|
|
||||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
|
||||||
box-shadow: 0 20px 25px rgba(0, 191, 255, 0.6);
|
|
||||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
|
||||||
animation: flicker 0.2s infinite alternate;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button.unavailable {
|
|
||||||
background: linear-gradient(45deg, #330000, #550000);
|
|
||||||
color: #ffb4b4;
|
|
||||||
border: 1px solid #550000;
|
|
||||||
box-shadow: none;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button.unavailable:hover {
|
|
||||||
background: linear-gradient(45deg, #330000, #550000);
|
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid #550000;
|
|
||||||
animation: none;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uninstall-button {
|
|
||||||
align-self: center;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
margin: 6px 0 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid #6c2942;
|
|
||||||
background: #2a0714;
|
|
||||||
color: #ffb4c8;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uninstall-button:hover {
|
|
||||||
border-color: #ff6d9d;
|
|
||||||
background: #4d1025;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flicker {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
50% { opacity: 0.8; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-directory-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-directory-message {
|
|
||||||
color: #8892b0;
|
|
||||||
font-size: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-directory-button {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #D5DBFE;
|
|
||||||
background: #000938;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 25px;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #4866b9;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 191, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input::placeholder {
|
|
||||||
color: #8892b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-settings-wrapper {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: linear-gradient(45deg, #09305a, #37529c);
|
|
||||||
color: #D5DBFE;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-button:hover {
|
|
||||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
|
||||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
|
||||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-text {
|
|
||||||
color: #8892b0;
|
|
||||||
font-size: 14px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-games-message {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
color: #8892b0;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
background: linear-gradient(to bottom, rgba(0, 9, 56, 0.3), rgba(0, 9, 56, 0.1));
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
min-height: 18px;
|
|
||||||
margin: 8px 10px 16px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: #8892b0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info.error {
|
|
||||||
color: #ff6666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: linear-gradient(45deg, #09305a, #37529c);
|
|
||||||
color: #D5DBFE;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button:hover {
|
|
||||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
|
||||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-button.active {
|
|
||||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
|
||||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
|
||||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 18px;
|
|
||||||
margin: 8px 10px 16px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: #8892b0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-left {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-count {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #4866b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-left-peer-count {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
@@ -1,881 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { MainWindow } from './windows/MainWindow';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
/**
|
||||||
import { load } from '@tauri-apps/plugin-store';
|
* Tauri can spawn this bundle in either the main launcher window or the
|
||||||
|
* unpack-logs companion window. The URL query string disambiguates the two so
|
||||||
import "./App.css";
|
* a single Vite build serves both.
|
||||||
|
*/
|
||||||
const FILE_STORAGE = 'launcher-settings.json';
|
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
|
||||||
const GAME_DIR_KEY = 'game-directory';
|
|
||||||
const CHECKING_PEERS_TIMEOUT_MS = 5000;
|
|
||||||
const FALLBACK_THUMBNAIL =
|
|
||||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A';
|
|
||||||
const STORE_OPTIONS = {
|
|
||||||
autoSave: true,
|
|
||||||
defaults: {
|
|
||||||
[GAME_DIR_KEY]: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// enum with install status
|
|
||||||
enum InstallStatus {
|
|
||||||
NotInstalled = 'NotInstalled',
|
|
||||||
CheckingPeers = 'CheckingPeers',
|
|
||||||
Downloading = 'Downloading',
|
|
||||||
Installing = 'Installing',
|
|
||||||
Uninstalling = 'Uninstalling',
|
|
||||||
Installed = 'Installed',
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatusLevel = 'info' | 'error';
|
|
||||||
|
|
||||||
type GameFilter = 'all' | 'local' | 'installed';
|
|
||||||
|
|
||||||
interface Game {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
size: number;
|
|
||||||
thumbnail: Uint8Array | number[];
|
|
||||||
downloaded: boolean;
|
|
||||||
installed: boolean;
|
|
||||||
availability: GameAvailability;
|
|
||||||
install_status: InstallStatus;
|
|
||||||
eti_game_version?: string;
|
|
||||||
local_version?: string;
|
|
||||||
status_message?: string;
|
|
||||||
status_level?: StatusLevel;
|
|
||||||
peer_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum GameAvailability {
|
|
||||||
Ready = 'Ready',
|
|
||||||
LocalOnly = 'LocalOnly',
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ActiveOperationKind {
|
|
||||||
Downloading = 'Downloading',
|
|
||||||
Installing = 'Installing',
|
|
||||||
Updating = 'Updating',
|
|
||||||
Uninstalling = 'Uninstalling',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveOperation {
|
|
||||||
id: string;
|
|
||||||
operation: ActiveOperationKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GamesListPayload {
|
|
||||||
games: Game[];
|
|
||||||
active_operations?: ActiveOperation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameThumbnailProps {
|
|
||||||
gameId: string;
|
|
||||||
alt: string;
|
|
||||||
getThumbnailUrl: (gameId: string) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => {
|
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadThumbnail = async () => {
|
|
||||||
const url = await getThumbnailUrl(gameId);
|
|
||||||
if (isMounted) {
|
|
||||||
setThumbnailUrl(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadThumbnail();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [gameId, getThumbnailUrl]);
|
|
||||||
|
|
||||||
if (!thumbnailUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <img src={thumbnailUrl} alt={alt} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
|
||||||
InstallStatus.CheckingPeers,
|
|
||||||
InstallStatus.Downloading,
|
|
||||||
InstallStatus.Installing,
|
|
||||||
InstallStatus.Uninstalling,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
|
||||||
InstallStatus.Downloading,
|
|
||||||
InstallStatus.Installing,
|
|
||||||
InstallStatus.Uninstalling,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isInProgressInstallStatus = (status: InstallStatus): boolean => {
|
|
||||||
return IN_PROGRESS_INSTALL_STATUSES.has(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReconciledOperationStatus = (status: InstallStatus): boolean => {
|
|
||||||
return RECONCILED_OPERATION_STATUSES.has(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const installStatusFromActiveOperation = (operation: ActiveOperationKind): InstallStatus => {
|
|
||||||
switch (operation) {
|
|
||||||
case ActiveOperationKind.Downloading:
|
|
||||||
return InstallStatus.Downloading;
|
|
||||||
case ActiveOperationKind.Installing:
|
|
||||||
case ActiveOperationKind.Updating:
|
|
||||||
return InstallStatus.Installing;
|
|
||||||
case ActiveOperationKind.Uninstalling:
|
|
||||||
return InstallStatus.Uninstalling;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeStatusById = (activeOperations: ActiveOperation[] = []): Map<string, InstallStatus> => {
|
|
||||||
return new Map(activeOperations.map(operation => [
|
|
||||||
operation.id,
|
|
||||||
installStatusFromActiveOperation(operation.operation),
|
|
||||||
]));
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesListPayload => {
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
return { games: payload };
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeGameUpdate = (
|
|
||||||
game: Game,
|
|
||||||
previous?: Game,
|
|
||||||
activeStatus?: InstallStatus,
|
|
||||||
hasAuthoritativeSnapshot = false,
|
|
||||||
): Game => {
|
|
||||||
let installStatus = InstallStatus.NotInstalled;
|
|
||||||
if (activeStatus !== undefined) {
|
|
||||||
installStatus = activeStatus;
|
|
||||||
} else if (game.installed) {
|
|
||||||
installStatus = InstallStatus.Installed;
|
|
||||||
} else if (
|
|
||||||
previous
|
|
||||||
&& isInProgressInstallStatus(previous.install_status)
|
|
||||||
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
|
||||||
) {
|
|
||||||
installStatus = previous.install_status;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localStateChanged = previous !== undefined
|
|
||||||
&& (previous.installed !== game.installed || previous.downloaded !== game.downloaded);
|
|
||||||
const activeStateReconciled = hasAuthoritativeSnapshot
|
|
||||||
&& (activeStatus !== undefined
|
|
||||||
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
|
||||||
const clearStatus = localStateChanged || activeStateReconciled;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...game,
|
|
||||||
availability: game.availability ?? (game.downloaded ? GameAvailability.Ready : GameAvailability.LocalOnly),
|
|
||||||
install_status: installStatus,
|
|
||||||
status_message: clearStatus ? undefined : previous?.status_message,
|
|
||||||
status_level: clearStatus ? undefined : previous?.status_level,
|
|
||||||
peer_count: game.peer_count ?? 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [gameItems, setGameItems] = useState<Game[]>([]);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [gameDir, setGameDir] = useState('');
|
|
||||||
const [currentFilter, setCurrentFilter] = useState<GameFilter>('local');
|
|
||||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
|
||||||
const checkingPeersTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
||||||
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
const getThumbnailUrl = useCallback(async (gameId: string): Promise<string> => {
|
|
||||||
// Check cache first
|
|
||||||
if (thumbnails.has(gameId)) {
|
|
||||||
return thumbnails.get(gameId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const thumbnailUrl = await invoke<string>('get_game_thumbnail', { gameId });
|
|
||||||
setThumbnails(prev => new Map(prev).set(gameId, thumbnailUrl));
|
|
||||||
return thumbnailUrl;
|
|
||||||
} catch {
|
|
||||||
// Return a small placeholder for missing images
|
|
||||||
setThumbnails(prev => new Map(prev).set(gameId, FALLBACK_THUMBNAIL));
|
|
||||||
return FALLBACK_THUMBNAIL;
|
|
||||||
}
|
|
||||||
}, [thumbnails]);
|
|
||||||
|
|
||||||
const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => {
|
|
||||||
switch (filter) {
|
|
||||||
case 'local':
|
|
||||||
// Games present on this machine, whether the archive is downloaded or already installed.
|
|
||||||
return games.filter(game => game.installed || game.downloaded);
|
|
||||||
case 'installed':
|
|
||||||
return games.filter(game => game.installed);
|
|
||||||
case 'all':
|
|
||||||
default:
|
|
||||||
// Games reachable on the LAN: held on this machine or advertised by another peer.
|
|
||||||
return games.filter(game => game.installed || game.downloaded || game.peer_count > 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredAndSearchedGames = getFilteredGames(gameItems, currentFilter).filter(item =>
|
|
||||||
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearCheckingPeersTimeout = (gameId: string) => {
|
|
||||||
const timeoutId = checkingPeersTimeouts.current[gameId];
|
|
||||||
if (timeoutId !== undefined) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
delete checkingPeersTimeouts.current[gameId];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleCheckingPeersFallback = (gameId: string, fallbackMessage?: string, fallbackLevel?: StatusLevel) => {
|
|
||||||
clearCheckingPeersTimeout(gameId);
|
|
||||||
checkingPeersTimeouts.current[gameId] = setTimeout(() => {
|
|
||||||
setGameItems(prev => prev.map(item => {
|
|
||||||
if (item.id !== gameId || item.install_status !== InstallStatus.CheckingPeers) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: fallbackMessage ?? 'No peers currently have this game.',
|
|
||||||
status_level: fallbackLevel ?? 'error',
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
delete checkingPeersTimeouts.current[gameId];
|
|
||||||
}, CHECKING_PEERS_TIMEOUT_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
Object.values(checkingPeersTimeouts.current).forEach(clearTimeout);
|
|
||||||
checkingPeersTimeouts.current = {};
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getInitialGameDir = useCallback(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
const store = await load(FILE_STORAGE, STORE_OPTIONS);
|
|
||||||
const savedGameDir = await store.get<string>(GAME_DIR_KEY);
|
|
||||||
if (savedGameDir) {
|
|
||||||
setGameDir(savedGameDir);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getInitialGameDir();
|
|
||||||
}, [getInitialGameDir]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Listen for game-download-failed events specifically
|
|
||||||
const setupDownloadFailedListener = async () => {
|
|
||||||
const unlisten = await listen('game-download-failed', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`❌ game-download-failed ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: 'Download failed. Please try again.',
|
|
||||||
status_level: 'error',
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
|
|
||||||
// Convert to string explicitly and verify it's not empty
|
|
||||||
const pathString = String(gameDir);
|
|
||||||
if (!pathString) {
|
|
||||||
console.error('gameDir is empty before invoke!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
invoke('update_game_directory', { path: pathString })
|
|
||||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupPeersGoneListener = async () => {
|
|
||||||
const unlisten = await listen('game-download-peers-gone', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`❌ game-download-peers-gone ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: 'Failed: All Peers gone',
|
|
||||||
status_level: 'error',
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
|
|
||||||
const pathString = String(gameDir);
|
|
||||||
if (!pathString) {
|
|
||||||
console.error('gameDir is empty before invoke!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
invoke('update_game_directory', { path: pathString })
|
|
||||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupNoPeersListener = async () => {
|
|
||||||
const unlisten = await listen('game-no-peers', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`⚠️ game-no-peers ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: 'No peers currently have this game.',
|
|
||||||
status_level: 'error',
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
};
|
|
||||||
|
|
||||||
setupDownloadFailedListener();
|
|
||||||
setupPeersGoneListener();
|
|
||||||
setupNoPeersListener();
|
|
||||||
|
|
||||||
const setupPeerCountListener = async () => {
|
|
||||||
const unlisten = await listen('peer-count-updated', (event) => {
|
|
||||||
const count = event.payload as number;
|
|
||||||
console.log(`🗲 peer-count-updated ${count} event received`);
|
|
||||||
setTotalPeerCount(count);
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
};
|
|
||||||
|
|
||||||
setupPeerCountListener();
|
|
||||||
}, [gameDir]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Listen for game-install-finished events specifically
|
|
||||||
const setupInstallFinishedListener = async () => {
|
|
||||||
const unlisten = await listen('game-install-finished', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-install-finished ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Installed,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
|
|
||||||
// Convert to string explicitly and verify it's not empty
|
|
||||||
const pathString = String(gameDir);
|
|
||||||
if (!pathString) {
|
|
||||||
console.error('gameDir is empty before invoke!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
invoke('update_game_directory', { path: pathString })
|
|
||||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
|
||||||
});
|
|
||||||
return unlisten;
|
|
||||||
};
|
|
||||||
|
|
||||||
setupInstallFinishedListener();
|
|
||||||
}, [gameDir]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameDir) {
|
|
||||||
// store game directory in persistent storage
|
|
||||||
const updateStorage = async (game_dir: string) => {
|
|
||||||
try {
|
|
||||||
const store = await load(FILE_STORAGE, STORE_OPTIONS);
|
|
||||||
await store.set(GAME_DIR_KEY, game_dir);
|
|
||||||
console.info(`📦 Storage updated with game directory: ${game_dir}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error updating storage:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateStorage(gameDir);
|
|
||||||
|
|
||||||
console.log(`📂 Game directory changed to: ${gameDir}`);
|
|
||||||
invoke('update_game_directory', { path: gameDir })
|
|
||||||
.catch(error => console.error('❌ Error updating game directory:', error));
|
|
||||||
}
|
|
||||||
}, [gameDir]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('🔵 Effect starting - setting up listener and requesting games');
|
|
||||||
|
|
||||||
const setupEventListener = async () => {
|
|
||||||
try {
|
|
||||||
// Listen for games-list-updated events
|
|
||||||
const unlisten_games = await listen('games-list-updated', (event) => {
|
|
||||||
console.log('🗲 Received games-list-updated event');
|
|
||||||
const payload = normalizeGamesListPayload(event.payload as GamesListPayload | Game[]);
|
|
||||||
const games = payload.games;
|
|
||||||
const activeStatuses = activeStatusById(payload.active_operations);
|
|
||||||
const hasAuthoritativeSnapshot = payload.active_operations !== undefined;
|
|
||||||
console.log(`🎮 ${games.length} Games received`);
|
|
||||||
setGameItems(prev => {
|
|
||||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
|
||||||
return games.map(game => mergeGameUpdate(
|
|
||||||
game,
|
|
||||||
previousById.get(game.id),
|
|
||||||
activeStatuses.get(game.id),
|
|
||||||
hasAuthoritativeSnapshot,
|
|
||||||
));
|
|
||||||
});
|
|
||||||
void getInitialGameDir();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for game-download-begin events
|
|
||||||
const unlisten_game_download_begin = await listen('game-download-begin', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-download-begin ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Downloading,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for game-download-finished events
|
|
||||||
const unlisten_game_download_finished = await listen('game-download-finished', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-download-finished ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Installing,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten_game_install_begin = await listen('game-install-begin', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-install-begin ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Installing,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten_game_install_failed = await listen('game-install-failed', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`❌ game-install-failed ${game_id} event received`);
|
|
||||||
clearCheckingPeersTimeout(game_id);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: 'Install failed. Please try again.',
|
|
||||||
status_level: 'error',
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten_game_uninstall_begin = await listen('game-uninstall-begin', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-uninstall-begin ${game_id} event received`);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Uninstalling,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten_game_uninstall_finished = await listen('game-uninstall-finished', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`🗲 game-uninstall-finished ${game_id} event received`);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
installed: false,
|
|
||||||
install_status: InstallStatus.NotInstalled,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten_game_uninstall_failed = await listen('game-uninstall-failed', (event) => {
|
|
||||||
const game_id = event.payload as string;
|
|
||||||
console.log(`❌ game-uninstall-failed ${game_id} event received`);
|
|
||||||
setGameItems(prev => prev.map(item => item.id === game_id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
|
|
||||||
status_message: 'Uninstall failed. Please try again.',
|
|
||||||
status_level: 'error',
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial request for games
|
|
||||||
console.log('📤 Requesting initial games list');
|
|
||||||
await invoke('request_games');
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
console.log('🧹 Cleaning up - removing listener');
|
|
||||||
unlisten_games();
|
|
||||||
unlisten_game_download_begin();
|
|
||||||
unlisten_game_download_finished();
|
|
||||||
unlisten_game_install_begin();
|
|
||||||
unlisten_game_install_failed();
|
|
||||||
unlisten_game_uninstall_begin();
|
|
||||||
unlisten_game_uninstall_finished();
|
|
||||||
unlisten_game_uninstall_failed();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in setup:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setupEventListener();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
console.log('🚫 Effect cleanup - component unmounting');
|
|
||||||
};
|
|
||||||
}, []); // Empty dependency array means this runs once on mount
|
|
||||||
|
|
||||||
const runGame = async (id: string) => {
|
|
||||||
console.log(`🎯 Running game with id=${id}`);
|
|
||||||
try {
|
|
||||||
const result = await invoke('run_game', { id });
|
|
||||||
console.log(`✅ Game started, result=${result}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error running game:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const installGame = async (id: string) => {
|
|
||||||
console.log(`🎯 Installing game with id=${id}`);
|
|
||||||
try {
|
|
||||||
const success = await invoke('install_game', { id });
|
|
||||||
if (success) {
|
|
||||||
console.log(`✅ Game install for id=${id} started...`);
|
|
||||||
let fallbackMessage: string | undefined;
|
|
||||||
let fallbackLevel: StatusLevel | undefined;
|
|
||||||
// update install status in gameItems for this game
|
|
||||||
setGameItems(prev => prev.map(item => {
|
|
||||||
if (item.id === id) {
|
|
||||||
fallbackMessage = item.status_message;
|
|
||||||
fallbackLevel = item.status_level;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.CheckingPeers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel);
|
|
||||||
} else {
|
|
||||||
// game is already being installed
|
|
||||||
console.warn(`🚧 Game with id=${id} is already being installed`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error installing game:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateGame = async (id: string) => {
|
|
||||||
console.log(`🎯 Updating game with id=${id}`);
|
|
||||||
try {
|
|
||||||
const success = await invoke('update_game', { id });
|
|
||||||
if (success) {
|
|
||||||
console.log(`✅ Game update for id=${id} started...`);
|
|
||||||
let fallbackMessage: string | undefined;
|
|
||||||
let fallbackLevel: StatusLevel | undefined;
|
|
||||||
// update install status in gameItems for this game
|
|
||||||
setGameItems(prev => prev.map(item => {
|
|
||||||
if (item.id === id) {
|
|
||||||
fallbackMessage = item.status_message;
|
|
||||||
fallbackLevel = item.status_level;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.CheckingPeers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel);
|
|
||||||
} else {
|
|
||||||
// game is already being installed/updated
|
|
||||||
console.warn(`🚧 Game with id=${id} is already being updated`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error updating game:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uninstallGame = async (id: string) => {
|
|
||||||
console.log(`🎯 Uninstalling game with id=${id}`);
|
|
||||||
try {
|
|
||||||
const success = await invoke('uninstall_game', { id });
|
|
||||||
if (success) {
|
|
||||||
setGameItems(prev => prev.map(item => item.id === id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
install_status: InstallStatus.Uninstalling,
|
|
||||||
status_message: undefined,
|
|
||||||
status_level: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error uninstalling game:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const needsUpdate = (game: Game): boolean => {
|
|
||||||
if (!game.installed) return false;
|
|
||||||
|
|
||||||
// Check if peers have a version and we have a local version
|
|
||||||
const peerVersion = game.eti_game_version;
|
|
||||||
const localVersion = game.local_version;
|
|
||||||
|
|
||||||
// If we don't have local version but peers have one, we need update
|
|
||||||
if (!localVersion && peerVersion) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have both versions, compare them numerically
|
|
||||||
if (localVersion && peerVersion) {
|
|
||||||
const localNum = parseInt(localVersion, 10);
|
|
||||||
const peerNum = parseInt(peerVersion, 10);
|
|
||||||
return peerNum > localNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInProgressLabel = (game: Game): string | undefined => {
|
|
||||||
switch (game.install_status) {
|
|
||||||
case InstallStatus.CheckingPeers:
|
|
||||||
return 'Checking peers...';
|
|
||||||
case InstallStatus.Downloading:
|
|
||||||
return 'Downloading...';
|
|
||||||
case InstallStatus.Installing:
|
|
||||||
return 'Installing...';
|
|
||||||
case InstallStatus.Uninstalling:
|
|
||||||
return 'Uninstalling...';
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isUnavailable = (game: Game): boolean => {
|
|
||||||
return !game.installed
|
|
||||||
&& !game.downloaded
|
|
||||||
&& game.peer_count === 0
|
|
||||||
&& game.install_status === InstallStatus.NotInstalled;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionLabel = (game: Game): string => {
|
|
||||||
const inProgress = getInProgressLabel(game);
|
|
||||||
if (inProgress) {
|
|
||||||
return inProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUnavailable(game)) {
|
|
||||||
return 'Unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!game.installed) {
|
|
||||||
return game.downloaded ? 'Install' : 'Download';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsUpdate(game)) {
|
|
||||||
return 'Update';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Play';
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogGameDir = async () => {
|
|
||||||
const file = await open({
|
|
||||||
multiple: false,
|
|
||||||
directory: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
setGameDir(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container">
|
|
||||||
<div className="fixed-header">
|
|
||||||
<div className="top-left-peer-count">
|
|
||||||
{totalPeerCount > 0 && (
|
|
||||||
<span className="peer-count">
|
|
||||||
👥 {totalPeerCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="align-center">SoftLAN Launcher</h1>
|
|
||||||
<div className="main-header">
|
|
||||||
{gameDir ? (
|
|
||||||
<div>
|
|
||||||
<div className="filter-container">
|
|
||||||
<button
|
|
||||||
className={`filter-button ${currentFilter === 'all' ? 'active' : ''}`}
|
|
||||||
onClick={() => setCurrentFilter('all')}
|
|
||||||
title="Show all games available on the LAN"
|
|
||||||
>
|
|
||||||
All Games
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`filter-button ${currentFilter === 'local' ? 'active' : ''}`}
|
|
||||||
onClick={() => setCurrentFilter('local')}
|
|
||||||
title="Show games downloaded or installed on your system"
|
|
||||||
>
|
|
||||||
Local
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`filter-button ${currentFilter === 'installed' ? 'active' : ''}`}
|
|
||||||
onClick={() => setCurrentFilter('installed')}
|
|
||||||
title="Show games installed on your system"
|
|
||||||
>
|
|
||||||
Installed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="search-settings-wrapper">
|
|
||||||
<div></div>
|
|
||||||
<div className="search-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search games..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="settings-container">
|
|
||||||
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
|
||||||
<span className="settings-text">{gameDir}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="no-directory-container">
|
|
||||||
<div className="no-directory-message">
|
|
||||||
Please set a game directory to start scanning for games...
|
|
||||||
</div>
|
|
||||||
<div className="no-directory-button">
|
|
||||||
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid-container">
|
|
||||||
{gameDir && filteredAndSearchedGames.length === 0 && gameItems.length === 0 ? (
|
|
||||||
<div className="no-games-message">
|
|
||||||
Scanning for games in your directory...
|
|
||||||
</div>
|
|
||||||
) : gameDir && filteredAndSearchedGames.length === 0 && gameItems.length > 0 ? (
|
|
||||||
<div className="no-games-message">
|
|
||||||
No games found matching your search and filters.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{filteredAndSearchedGames.map((item) => (
|
|
||||||
<div key={item.id} className="item">
|
|
||||||
<GameThumbnail
|
|
||||||
gameId={item.id}
|
|
||||||
alt={`${item.name} thumbnail`}
|
|
||||||
getThumbnailUrl={getThumbnailUrl}
|
|
||||||
/>
|
|
||||||
<div className="item-name">{item.name}</div>
|
|
||||||
<div className="description">
|
|
||||||
<span className="desc-text">{item.description.slice(0, 10)}</span>
|
|
||||||
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
|
|
||||||
</div>
|
|
||||||
<div className="badges">
|
|
||||||
{item.installed && item.availability === GameAvailability.LocalOnly && (
|
|
||||||
<span className="badge local-only">LocalOnly</span>
|
|
||||||
)}
|
|
||||||
{!item.installed && item.downloaded && item.local_version && (
|
|
||||||
<span className="badge">v{item.local_version}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isUnavailable(item)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.installed) {
|
|
||||||
installGame(item.id);
|
|
||||||
} else if (needsUpdate(item)) {
|
|
||||||
updateGame(item.id);
|
|
||||||
} else {
|
|
||||||
runGame(item.id);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{getActionLabel(item)}
|
|
||||||
</div>
|
|
||||||
{item.installed && !isInProgressInstallStatus(item.install_status) && (
|
|
||||||
<button
|
|
||||||
className="uninstall-button"
|
|
||||||
aria-label={`Uninstall ${item.name}`}
|
|
||||||
title="Uninstall"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
uninstallGame(item.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
|
|
||||||
<div className="status-left">
|
|
||||||
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}
|
|
||||||
</div>
|
|
||||||
<div className="status-right">
|
|
||||||
{item.peer_count > 0 && (
|
|
||||||
<span className="peer-count">
|
|
||||||
👥 {item.peer_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
.unpack-log-window {
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 18px;
|
||||||
|
background: #000313;
|
||||||
|
color: #D5DBFE;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #aeb7df;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-regex {
|
||||||
|
flex: 0 1 320px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #D5DBFE;
|
||||||
|
background: #050813;
|
||||||
|
border: 1px solid #2a3252;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-regex:focus {
|
||||||
|
border-color: #4866b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-regex.invalid {
|
||||||
|
border-color: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-regex-error {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #ff8a8a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 320px) 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #2a3252;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #050813;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-stats {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px 6px;
|
||||||
|
border-bottom: 1px solid #26304f;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
color: #D5DBFE;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-item:hover {
|
||||||
|
background: #0d1530;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-item.active {
|
||||||
|
background: #14224d;
|
||||||
|
border-color: #4866b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-badge {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-item.success .unpack-log-list-badge {
|
||||||
|
color: #050813;
|
||||||
|
background: #8ee6a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-item.error .unpack-log-list-badge {
|
||||||
|
color: #050813;
|
||||||
|
background: #ff8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-time {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-list-matches {
|
||||||
|
color: #aeb7df;
|
||||||
|
background: #14224d;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-detail {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #2a3252;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #050813;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-detail-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-detail-pos {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-empty {
|
||||||
|
color: #8892b0;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-empty-stream {
|
||||||
|
color: #8892b0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-meta {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-meta.success {
|
||||||
|
color: #8ee6a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-meta.error,
|
||||||
|
.unpack-log-stream.stderr {
|
||||||
|
color: #ff8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-path {
|
||||||
|
color: #aeb7df;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-stream {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpack-log-stream.stdout {
|
||||||
|
color: #D5DBFE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import './UnpackLogsWindow.css';
|
||||||
|
|
||||||
|
interface UnpackLogEntry {
|
||||||
|
archive: string;
|
||||||
|
destination: string;
|
||||||
|
status_code: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
started_at_ms: number;
|
||||||
|
finished_at_ms: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilteredEntry {
|
||||||
|
entry: UnpackLogEntry;
|
||||||
|
originalIndex: number;
|
||||||
|
stdoutLines: string[];
|
||||||
|
stderrLines: string[];
|
||||||
|
matchCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUnpackLogsView = (): boolean =>
|
||||||
|
new URLSearchParams(window.location.search).get('view') === 'unpack-logs';
|
||||||
|
|
||||||
|
const formatLogTime = (timestampMs: number): string => {
|
||||||
|
if (timestampMs <= 0) {
|
||||||
|
return 'unknown time';
|
||||||
|
}
|
||||||
|
return new Date(timestampMs).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const basename = (path: string): string => {
|
||||||
|
const segments = path.split(/[\\/]/);
|
||||||
|
return segments[segments.length - 1] || path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nonEmptyLines = (text: string): string[] =>
|
||||||
|
text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||||||
|
|
||||||
|
export const UnpackLogsWindow = () => {
|
||||||
|
const [logs, setLogs] = useState<UnpackLogEntry[]>([]);
|
||||||
|
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||||
|
const [regexInput, setRegexInput] = useState('');
|
||||||
|
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const refreshLogs = useCallback(async () => {
|
||||||
|
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
|
||||||
|
setLogs(unpackLogs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
await refreshLogs();
|
||||||
|
unlisten = await listen('unpack-logs-updated', () => {
|
||||||
|
void refreshLogs();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void setup();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [refreshLogs]);
|
||||||
|
|
||||||
|
const { regex, regexError } = useMemo(() => {
|
||||||
|
if (!regexInput) {
|
||||||
|
return { regex: null as RegExp | null, regexError: null as string | null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { regex: new RegExp(regexInput, 'i'), regexError: null };
|
||||||
|
} catch (e) {
|
||||||
|
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}, [regexInput]);
|
||||||
|
|
||||||
|
const filteredLogs = useMemo<FilteredEntry[]>(() => {
|
||||||
|
const out: FilteredEntry[] = [];
|
||||||
|
logs.forEach((entry, originalIndex) => {
|
||||||
|
if (errorsOnly && entry.success) return;
|
||||||
|
|
||||||
|
const stdoutClean = nonEmptyLines(entry.stdout);
|
||||||
|
const stderrClean = nonEmptyLines(entry.stderr);
|
||||||
|
|
||||||
|
const stdoutLines = regex ? stdoutClean.filter(line => regex.test(line)) : stdoutClean;
|
||||||
|
const stderrLines = regex ? stderrClean.filter(line => regex.test(line)) : stderrClean;
|
||||||
|
const matchCount = stdoutLines.length + stderrLines.length;
|
||||||
|
|
||||||
|
// With an active regex, hide entries that contribute no matching lines.
|
||||||
|
if (regex && matchCount === 0) return;
|
||||||
|
|
||||||
|
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, [logs, errorsOnly, regex]);
|
||||||
|
|
||||||
|
const selectedListIndex = useMemo(() => {
|
||||||
|
if (filteredLogs.length === 0) return -1;
|
||||||
|
if (selectedOriginalIndex === null) return 0;
|
||||||
|
const idx = filteredLogs.findIndex(item => item.originalIndex === selectedOriginalIndex);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [filteredLogs, selectedOriginalIndex]);
|
||||||
|
|
||||||
|
const current = selectedListIndex >= 0 ? filteredLogs[selectedListIndex] : null;
|
||||||
|
|
||||||
|
const goTo = useCallback((listIndex: number) => {
|
||||||
|
if (listIndex < 0 || listIndex >= filteredLogs.length) return;
|
||||||
|
setSelectedOriginalIndex(filteredLogs[listIndex].originalIndex);
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
goTo(selectedListIndex - 1);
|
||||||
|
} else if (e.key === 'ArrowDown' || e.key === 'j') {
|
||||||
|
e.preventDefault();
|
||||||
|
goTo(selectedListIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [goTo, selectedListIndex]);
|
||||||
|
|
||||||
|
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
activeItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedListIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="unpack-log-window">
|
||||||
|
<div className="unpack-log-header">
|
||||||
|
<h1>Unpack Logs</h1>
|
||||||
|
<div className="unpack-log-controls">
|
||||||
|
<label className="unpack-log-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={errorsOnly}
|
||||||
|
onChange={(e) => setErrorsOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Errors only
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={`unpack-log-regex ${regexError ? 'invalid' : ''}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter lines by regex (case-insensitive)..."
|
||||||
|
value={regexInput}
|
||||||
|
onChange={(e) => setRegexInput(e.target.value)}
|
||||||
|
title={regexError ?? ''}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{regexError && (
|
||||||
|
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
||||||
|
)}
|
||||||
|
<div className="unpack-log-body">
|
||||||
|
<aside className="unpack-log-list">
|
||||||
|
<div className="unpack-log-list-stats">
|
||||||
|
showing {filteredLogs.length} of {logs.length}
|
||||||
|
</div>
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div className="unpack-log-empty">
|
||||||
|
{logs.length === 0 ? 'No unpack logs recorded yet.' : 'No logs match the current filters.'}
|
||||||
|
</div>
|
||||||
|
) : filteredLogs.map((item, listIndex) => {
|
||||||
|
const isActive = listIndex === selectedListIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${item.originalIndex}-${item.entry.finished_at_ms}`}
|
||||||
|
ref={isActive ? activeItemRef : undefined}
|
||||||
|
className={`unpack-log-list-item ${isActive ? 'active' : ''} ${item.entry.success ? 'success' : 'error'}`}
|
||||||
|
onClick={() => goTo(listIndex)}
|
||||||
|
>
|
||||||
|
<span className="unpack-log-list-badge">
|
||||||
|
{item.entry.success ? 'OK' : 'FAIL'}
|
||||||
|
</span>
|
||||||
|
<span className="unpack-log-list-name" title={item.entry.archive}>
|
||||||
|
{basename(item.entry.archive)}
|
||||||
|
</span>
|
||||||
|
<span className="unpack-log-list-time">
|
||||||
|
{item.entry.finished_at_ms > 0
|
||||||
|
? new Date(item.entry.finished_at_ms).toLocaleTimeString()
|
||||||
|
: '--:--:--'}
|
||||||
|
</span>
|
||||||
|
{regex && (
|
||||||
|
<span className="unpack-log-list-matches">{item.matchCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
<section className="unpack-log-detail">
|
||||||
|
{current ? (
|
||||||
|
<>
|
||||||
|
<div className="unpack-log-detail-nav">
|
||||||
|
<button
|
||||||
|
className="settings-button"
|
||||||
|
onClick={() => goTo(selectedListIndex - 1)}
|
||||||
|
disabled={selectedListIndex <= 0}
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span className="unpack-log-detail-pos">
|
||||||
|
{selectedListIndex + 1} / {filteredLogs.length}
|
||||||
|
{regex && ` · ${current.matchCount} matching lines`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="settings-button"
|
||||||
|
onClick={() => goTo(selectedListIndex + 1)}
|
||||||
|
disabled={selectedListIndex >= filteredLogs.length - 1}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<article className="unpack-log-entry">
|
||||||
|
<div className={`unpack-log-meta ${current.entry.success ? 'success' : 'error'}`}>
|
||||||
|
[{formatLogTime(current.entry.finished_at_ms)}] {current.entry.success ? 'OK' : 'FAILED'}
|
||||||
|
{' '}status={current.entry.status_code ?? 'none'}
|
||||||
|
</div>
|
||||||
|
<div className="unpack-log-path">archive: {current.entry.archive}</div>
|
||||||
|
<div className="unpack-log-path">dest: {current.entry.destination}</div>
|
||||||
|
{current.stdoutLines.length > 0 ? (
|
||||||
|
<pre className="unpack-log-stream stdout">{current.stdoutLines.join('\n')}</pre>
|
||||||
|
) : (
|
||||||
|
<div className="unpack-log-empty-stream">
|
||||||
|
{regex ? '(stdout: no matching lines)' : '(stdout empty)'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{current.stderrLines.length > 0 && (
|
||||||
|
<pre className="unpack-log-stream stderr">{current.stderrLines.join('\n')}</pre>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="unpack-log-empty">Nothing to show.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { JSX, MouseEvent } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import { Game } from '../lib/types';
|
||||||
|
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: Game;
|
||||||
|
size?: 'md' | 'lg';
|
||||||
|
full?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
|
||||||
|
play: <Icon.play />,
|
||||||
|
install: <Icon.install />,
|
||||||
|
update: <Icon.install />,
|
||||||
|
download: <Icon.download />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Color-coded primary action: Play / Install / Update / Download / busy. */
|
||||||
|
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
|
||||||
|
const action = primaryActionFor(game);
|
||||||
|
const cls = [
|
||||||
|
'act-btn',
|
||||||
|
`act-${action}`,
|
||||||
|
size === 'lg' ? 'act-lg' : '',
|
||||||
|
full ? 'act-full' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const disabled = action === 'busy' || action === 'disabled';
|
||||||
|
|
||||||
|
const handle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled) return;
|
||||||
|
onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={cls} onClick={handle} disabled={disabled}>
|
||||||
|
{ICON_FOR_ACTION[action]}
|
||||||
|
<span>{actionLabel(game)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface Props {
|
||||||
|
peerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Brand = ({ peerCount }: Props) => (
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark">S</div>
|
||||||
|
<div className="brand-name">SoftLAN</div>
|
||||||
|
{peerCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="brand-peers"
|
||||||
|
title={`${peerCount} peer${peerCount === 1 ? '' : 's'} online`}
|
||||||
|
>
|
||||||
|
<span className="brand-peers-dot" />
|
||||||
|
{peerCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Swatch {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
options: ReadonlyArray<Swatch>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorSwatchPicker = ({ value, options, onChange }: Props) => (
|
||||||
|
<div className="swatch-row">
|
||||||
|
{options.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className={`swatch${value === o.value ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
style={{ color: o.value }}
|
||||||
|
title={o.label}
|
||||||
|
aria-label={o.label}
|
||||||
|
>
|
||||||
|
<span className="swatch-dot" style={{ background: o.value }} />
|
||||||
|
{value === o.value && (
|
||||||
|
<span className="swatch-check">
|
||||||
|
<Icon.check />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { JSX, SVGProps } from 'react';
|
||||||
|
|
||||||
|
type Props = SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
const baseStroke: Partial<Props> = {
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Icon = {
|
||||||
|
search: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||||
|
<circle cx="7" cy="7" r="5" />
|
||||||
|
<path d="m13.5 13.5-3-3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
play: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}>
|
||||||
|
<path d="M4 2.5v11l10-5.5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
install: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...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: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...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: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.5} {...baseStroke} {...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>
|
||||||
|
),
|
||||||
|
kebab: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}>
|
||||||
|
<circle cx="8" cy="3.2" r="1.4" />
|
||||||
|
<circle cx="8" cy="8" r="1.4" />
|
||||||
|
<circle cx="8" cy="12.8" r="1.4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sort: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||||
|
<path d="M3 4h10" />
|
||||||
|
<path d="M4.5 8h7" />
|
||||||
|
<path d="M6 12h4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
users: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||||
|
<circle cx="6" cy="6" r="2.4" />
|
||||||
|
<path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13" />
|
||||||
|
<circle cx="11.2" cy="5.4" r="1.8" />
|
||||||
|
<path d="M10.4 9.8c1.7 0 3 1 3.6 2.6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
close: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||||
|
<path d="m4 4 8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
check: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}>
|
||||||
|
<path d="m3 8 3.5 3.5L13 5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevron: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="11" height="11" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||||
|
<path d="m4 6 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
trash: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||||
|
<path d="M3 4.5h10" />
|
||||||
|
<path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" />
|
||||||
|
<path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
games: (p: Props) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="22" height="22" strokeWidth={1.4} {...baseStroke} {...p}>
|
||||||
|
<rect x="2" y="5" width="12" height="8" rx="2" />
|
||||||
|
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
} satisfies Record<string, (p: Props) => JSX.Element>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic modal scrim + panel container. Closes on scrim click and Esc.
|
||||||
|
* Click events inside the panel are stopped so children can decide their own
|
||||||
|
* dismiss behaviour.
|
||||||
|
*/
|
||||||
|
export const Modal = ({ onClose, children, className }: Props) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-scrim" onClick={onClose} role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className={className ? `modal ${className}` : 'modal'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
interface Option<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
value: T;
|
||||||
|
options: ReadonlyArray<Option<T>>;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SegmentedRadio = <T extends string>({ value, options, onChange }: Props<T>) => (
|
||||||
|
<div className="srad">
|
||||||
|
{options.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
className={`srad-btn${value === o.value ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Game } from '../lib/types';
|
||||||
|
import { deriveState } from '../lib/gameState';
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
installed: 'Installed',
|
||||||
|
local: 'Local',
|
||||||
|
busy: 'Working',
|
||||||
|
none: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: Game;
|
||||||
|
/** Render even for `none` (used in the detail modal). */
|
||||||
|
showNone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StateChip = ({ game, showNone = false }: Props) => {
|
||||||
|
const state = deriveState(game);
|
||||||
|
const label = LABELS[state] ?? '';
|
||||||
|
if (!label && !showNone) return null;
|
||||||
|
return (
|
||||||
|
<div className="state-chip" data-state={state}>
|
||||||
|
<span className="state-dot" />
|
||||||
|
{label || 'Not downloaded'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Icon } from '../Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyResultsState = ({ title, hint }: Props) => (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><Icon.games /></div>
|
||||||
|
<h2 className="empty-state-title">{title}</h2>
|
||||||
|
<p className="empty-state-hint">{hint}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Icon } from '../Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onChooseDirectory: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><Icon.folder /></div>
|
||||||
|
<h2 className="empty-state-title">Pick a game directory</h2>
|
||||||
|
<p className="empty-state-hint">
|
||||||
|
SoftLAN scans the folder you point it at for installable game bundles
|
||||||
|
and tracks what your peers on the LAN have available.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
||||||
|
<Icon.folder />
|
||||||
|
<span>Choose folder</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { JSX, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
import { Game } from '../../lib/types';
|
||||||
|
import { CoverAspect } from '../../hooks/useSettings';
|
||||||
|
import { formatBytes } from '../../lib/format';
|
||||||
|
|
||||||
|
import { GameCover } from './GameCover';
|
||||||
|
import { StateChip } from '../StateChip';
|
||||||
|
import { ActionButton } from '../ActionButton';
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: Game;
|
||||||
|
aspect: CoverAspect;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
onOpen: (game: Game) => void;
|
||||||
|
onPrimary: (game: Game) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
||||||
|
const filtered = parts.filter(Boolean) as string[];
|
||||||
|
const out: JSX.Element[] = [];
|
||||||
|
filtered.forEach((p, i) => {
|
||||||
|
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||||
|
out.push(<span key={`p${i}`}>{p}</span>);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => {
|
||||||
|
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen(game);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="card"
|
||||||
|
onClick={() => onOpen(game)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
aria-label={game.name}
|
||||||
|
>
|
||||||
|
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||||
|
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||||
|
<StateChip game={game} />
|
||||||
|
{game.peer_count > 0 && (
|
||||||
|
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
|
||||||
|
<Icon.users />
|
||||||
|
<span>{game.peer_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-title" title={game.name}>{game.name}</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||||
|
</div>
|
||||||
|
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||||
|
{game.status_message ?? ''}
|
||||||
|
</div>
|
||||||
|
<ActionButton game={game} full onClick={() => onPrimary(game)} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { CSSProperties, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Game } from '../../lib/types';
|
||||||
|
import { coverColorsFor, titleFontSize } from '../../lib/cover';
|
||||||
|
import { CoverAspect } from '../../hooks/useSettings';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: Game;
|
||||||
|
aspect: CoverAspect;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
/** Hide the cover-bottom title overlay (used inside the detail modal hero). */
|
||||||
|
hideTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover art. When a real thumbnail is available it's rendered as the
|
||||||
|
* background image with the same gradient/vignette overlays as the
|
||||||
|
* placeholder; otherwise the design's procedurally-generated gradient stands
|
||||||
|
* in. The Bebas Neue title overlay is rendered on top of either.
|
||||||
|
*/
|
||||||
|
export const GameCover = ({ game, aspect, thumbnailUrl, hideTitle = false }: Props) => {
|
||||||
|
const colors = useMemo(() => coverColorsFor(game.id), [game.id]);
|
||||||
|
const hasThumbnail = Boolean(thumbnailUrl);
|
||||||
|
// Real cover art already contains its own title; only burn the Bebas Neue
|
||||||
|
// overlay onto the procedurally-generated placeholder.
|
||||||
|
const showOverlayTitle = !hideTitle && !hasThumbnail;
|
||||||
|
const titleStyle: CSSProperties = {
|
||||||
|
fontSize: titleFontSize(game.name, aspect),
|
||||||
|
textShadow: `0 4px 16px ${colors.c2}aa, 0 1px 0 rgba(0,0,0,.3)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cover">
|
||||||
|
{hasThumbnail ? (
|
||||||
|
<img className="cover-image" src={thumbnailUrl!} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="cover-base"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(${colors.angle}deg, ${colors.c1} 0%, ${colors.c2} 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="cover-blob"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(ellipse at ${colors.blobX}% ${colors.blobY}%, ${colors.accent}38, transparent 55%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="cover-grain" />
|
||||||
|
{showOverlayTitle && (
|
||||||
|
<div className="cover-titlewrap">
|
||||||
|
<div className="cover-title" style={titleStyle}>
|
||||||
|
{game.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cover-vignette" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Game } from '../../lib/types';
|
||||||
|
import { CoverAspect } from '../../hooks/useSettings';
|
||||||
|
|
||||||
|
import { GameCard } from './GameCard';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
games: Game[];
|
||||||
|
aspect: CoverAspect;
|
||||||
|
getThumbnail: (id: string) => string | null;
|
||||||
|
onOpen: (game: Game) => void;
|
||||||
|
onPrimary: (game: Game) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => (
|
||||||
|
<div className="grid">
|
||||||
|
{games.map(g => (
|
||||||
|
<GameCard
|
||||||
|
key={g.id}
|
||||||
|
game={g}
|
||||||
|
aspect={aspect}
|
||||||
|
thumbnailUrl={getThumbnail(g.id)}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onPrimary={onPrimary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
interface Props {
|
||||||
|
shown: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResultsBar = ({ shown, total }: Props) => (
|
||||||
|
<div className="results-bar">
|
||||||
|
<div className="results-count">
|
||||||
|
Showing <strong>{shown}</strong> of {total} games
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { GameCover } from '../grid/GameCover';
|
||||||
|
import { StateChip } from '../StateChip';
|
||||||
|
import { ActionButton } from '../ActionButton';
|
||||||
|
|
||||||
|
import { Game } from '../../lib/types';
|
||||||
|
import { deriveState } from '../../lib/gameState';
|
||||||
|
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: Game;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onPrimary: (game: Game) => void;
|
||||||
|
onUninstall: (game: Game) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsFromGame = (game: Game): string[] => {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (game.genre) tags.push(game.genre);
|
||||||
|
if (game.publisher) tags.push(game.publisher);
|
||||||
|
if (game.release_year) tags.push(game.release_year);
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabelFor = (game: Game): string => {
|
||||||
|
switch (deriveState(game)) {
|
||||||
|
case 'installed': return 'Installed';
|
||||||
|
case 'local': return 'Downloaded';
|
||||||
|
case 'busy': return 'Working…';
|
||||||
|
case 'none': return 'Not downloaded';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUninstall }: Props) => {
|
||||||
|
const tags = tagsFromGame(game);
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<Icon.close />
|
||||||
|
</button>
|
||||||
|
<div className="modal-hero">
|
||||||
|
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
||||||
|
<div className="modal-hero-fade" />
|
||||||
|
<div className="modal-hero-text">
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="modal-tags">
|
||||||
|
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="modal-title">{game.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="modal-state">
|
||||||
|
<StateChip game={game} showNone />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-meta">
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Size</div>
|
||||||
|
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Players</div>
|
||||||
|
<div className="meta-value">
|
||||||
|
<Icon.users /> {formatPlayers(game.max_players)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Version</div>
|
||||||
|
<div className="meta-value meta-mono">
|
||||||
|
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Status</div>
|
||||||
|
<div className="meta-value">{statusLabelFor(game)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{game.description && (
|
||||||
|
<p className="modal-desc">{game.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{game.status_message && (
|
||||||
|
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||||
|
{game.status_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<ActionButton game={game} size="lg" onClick={() => onPrimary(game)} />
|
||||||
|
{game.installed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn ghost-danger"
|
||||||
|
onClick={() => onUninstall(game)}
|
||||||
|
>
|
||||||
|
<Icon.trash />
|
||||||
|
<span>Uninstall</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { ColorSwatchPicker } from '../ColorSwatchPicker';
|
||||||
|
import { SegmentedRadio } from '../SegmentedRadio';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACCENT_OPTIONS,
|
||||||
|
ASPECT_OPTIONS,
|
||||||
|
BG_OPTIONS,
|
||||||
|
DENSITY_OPTIONS,
|
||||||
|
UISettings,
|
||||||
|
} from '../../hooks/useSettings';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: UISettings;
|
||||||
|
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ label, hint, children }: RowProps) => (
|
||||||
|
<div className="settings-row">
|
||||||
|
<div className="settings-row-info">
|
||||||
|
<div className="settings-row-label">{label}</div>
|
||||||
|
<div className="settings-row-hint">{hint}</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-row-control">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||||
|
<Modal onClose={onClose} className="settings-modal">
|
||||||
|
<div className="settings-head">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button
|
||||||
|
className="modal-close settings-close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<Icon.close />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-body">
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">Appearance</div>
|
||||||
|
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||||
|
<ColorSwatchPicker
|
||||||
|
value={settings.accent}
|
||||||
|
options={ACCENT_OPTIONS}
|
||||||
|
onChange={(v) => onChange('accent', v)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label="Background" hint="Backdrop behind the library">
|
||||||
|
<SegmentedRadio
|
||||||
|
value={settings.bg}
|
||||||
|
options={BG_OPTIONS}
|
||||||
|
onChange={(v) => onChange('bg', v)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">Library</div>
|
||||||
|
<Row label="Grid density" hint="How tightly cards are packed">
|
||||||
|
<SegmentedRadio
|
||||||
|
value={settings.density}
|
||||||
|
options={DENSITY_OPTIONS}
|
||||||
|
onChange={(v) => onChange('density', v)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label="Cover aspect" hint="Shape of the cover art on each card">
|
||||||
|
<SegmentedRadio
|
||||||
|
value={settings.aspect}
|
||||||
|
options={ASPECT_OPTIONS}
|
||||||
|
onChange={(v) => onChange('aspect', v)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-foot">
|
||||||
|
<button type="button" className="settings-done" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { truncatePath } from '../../lib/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DirectoryButton = ({ path, onClick }: Props) => (
|
||||||
|
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
|
||||||
|
<Icon.folder />
|
||||||
|
<span className="dirbtn-label">Game directory</span>
|
||||||
|
<span className="dirbtn-path">
|
||||||
|
{path ? truncatePath(path) : 'choose…'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
|
||||||
|
export type KebabItem =
|
||||||
|
| { kind: 'item'; label: string; onClick: () => void }
|
||||||
|
| { kind: 'separator' };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: ReadonlyArray<KebabItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KebabMenu = ({ items }: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kebab" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kebab-btn"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
aria-label="More"
|
||||||
|
>
|
||||||
|
<Icon.kebab />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="kebab-menu">
|
||||||
|
{items.map((it, i) =>
|
||||||
|
it.kind === 'separator' ? (
|
||||||
|
<div key={i} className="kebab-sep" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
it.onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
|
||||||
|
* when the user is already typing into another input or textarea.
|
||||||
|
*/
|
||||||
|
export const SearchField = ({ value, onChange }: Props) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== '/') return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<Icon.search />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search games"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="search-kbd">/</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { FilterCounts } from '../../lib/gameState';
|
||||||
|
import { GameFilter } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
key: GameFilter;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: ReadonlyArray<Tab> = [
|
||||||
|
{ key: 'all', label: 'All Games' },
|
||||||
|
{ key: 'local', label: 'Local' },
|
||||||
|
{ key: 'installed', label: 'Installed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: GameFilter;
|
||||||
|
onChange: (value: GameFilter) => void;
|
||||||
|
counts: FilterCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pill-style filter with an animated thumb that slides under the active tab. */
|
||||||
|
export const SegmentedFilters = ({ value, onChange, counts }: Props) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [thumb, setThumb] = useState({ left: 0, width: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const active = containerRef.current.querySelector<HTMLElement>(`[data-key="${value}"]`);
|
||||||
|
if (active) setThumb({ left: active.offsetLeft, width: active.offsetWidth });
|
||||||
|
}, [value, counts.all, counts.local, counts.installed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="seg" ref={containerRef}>
|
||||||
|
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width }} />
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
data-key={t.key}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn${value === t.key ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onChange(t.key)}
|
||||||
|
>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
<span className="seg-count">{counts[t.key]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { GameSort } from '../../lib/types';
|
||||||
|
|
||||||
|
const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [
|
||||||
|
{ key: 'az', label: 'Name (A–Z)' },
|
||||||
|
{ key: 'size', label: 'Size (largest)' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: GameSort;
|
||||||
|
onChange: (value: GameSort) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortMenu = ({ value, onChange }: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const current = OPTIONS.find(o => o.key === value) ?? OPTIONS[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sort" ref={ref}>
|
||||||
|
<button className="sort-btn" type="button" onClick={() => setOpen(o => !o)}>
|
||||||
|
<Icon.sort />
|
||||||
|
<span>Sort: <strong>{current.label}</strong></span>
|
||||||
|
<Icon.chevron />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{OPTIONS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(o.key);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sort-check">
|
||||||
|
{o.key === value && <Icon.check />}
|
||||||
|
</span>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Brand } from '../Brand';
|
||||||
|
import { SegmentedFilters } from './SegmentedFilters';
|
||||||
|
import { SearchField } from './SearchField';
|
||||||
|
import { SortMenu } from './SortMenu';
|
||||||
|
import { DirectoryButton } from './DirectoryButton';
|
||||||
|
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||||
|
|
||||||
|
import { FilterCounts } from '../../lib/gameState';
|
||||||
|
import { GameFilter, GameSort } from '../../lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
peerCount: number;
|
||||||
|
filter: GameFilter;
|
||||||
|
setFilter: (value: GameFilter) => void;
|
||||||
|
counts: FilterCounts;
|
||||||
|
query: string;
|
||||||
|
setQuery: (value: string) => void;
|
||||||
|
sort: GameSort;
|
||||||
|
setSort: (value: GameSort) => void;
|
||||||
|
gameDir: string;
|
||||||
|
onPickDirectory: () => void;
|
||||||
|
kebabItems: ReadonlyArray<KebabItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopBar = ({
|
||||||
|
peerCount,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
counts,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
gameDir,
|
||||||
|
onPickDirectory,
|
||||||
|
kebabItems,
|
||||||
|
}: Props) => (
|
||||||
|
<header className="topbar">
|
||||||
|
<Brand peerCount={peerCount} />
|
||||||
|
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
||||||
|
<SearchField value={query} onChange={setQuery} />
|
||||||
|
<SortMenu value={sort} onChange={setSort} />
|
||||||
|
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
|
||||||
|
<KebabMenu items={kebabItems} />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
import { UseGamesResult } from './useGames';
|
||||||
|
|
||||||
|
export interface GameActions {
|
||||||
|
play: (id: string) => Promise<void>;
|
||||||
|
install: (id: string) => Promise<void>;
|
||||||
|
update: (id: string) => Promise<void>;
|
||||||
|
uninstall: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||||
|
* / `uninstall_game` commands. For install + update we mark the game as
|
||||||
|
* "checking peers" up-front through the games hook so the UI doesn't have to
|
||||||
|
* wait for the first backend event.
|
||||||
|
*/
|
||||||
|
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||||
|
const play = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('run_game', { id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('run_game failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const install = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('install_game', { id });
|
||||||
|
if (success) games.markChecking(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('install_game failed:', err);
|
||||||
|
}
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
const update = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('update_game', { id });
|
||||||
|
if (success) games.markChecking(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('update_game failed:', err);
|
||||||
|
}
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
const uninstall = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('uninstall_game', { id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('uninstall_game failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { play, install, update, uninstall };
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { load } from '@tauri-apps/plugin-store';
|
||||||
|
|
||||||
|
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the user's selected game directory. Hydrates from the persistent store
|
||||||
|
* on mount, writes back on every change, and pushes the value to the Tauri
|
||||||
|
* backend so it can scan/rescan.
|
||||||
|
*/
|
||||||
|
export const useGameDirectory = () => {
|
||||||
|
const [gameDir, setGameDir] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const hydrate = async () => {
|
||||||
|
try {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
const saved = await store.get<string>(GAME_DIR_KEY);
|
||||||
|
if (saved && !cancelled) setGameDir(saved);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load game directory:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void hydrate();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameDir) return;
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
await store.set(GAME_DIR_KEY, gameDir);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist game directory:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void sync();
|
||||||
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
|
console.error('Failed to push game directory to backend:', err),
|
||||||
|
);
|
||||||
|
}, [gameDir]);
|
||||||
|
|
||||||
|
const rescan = useCallback(() => {
|
||||||
|
if (!gameDir) return;
|
||||||
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
|
console.error('Failed to rescan game directory:', err),
|
||||||
|
);
|
||||||
|
}, [gameDir]);
|
||||||
|
|
||||||
|
return { gameDir, setGameDir, rescan };
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Game,
|
||||||
|
GamesListPayload,
|
||||||
|
InstallStatus,
|
||||||
|
StatusLevel,
|
||||||
|
} from '../lib/types';
|
||||||
|
import {
|
||||||
|
activeStatusById,
|
||||||
|
mergeGameUpdate,
|
||||||
|
normalizeGamesListPayload,
|
||||||
|
} from '../lib/gameState';
|
||||||
|
|
||||||
|
const CHECKING_PEERS_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
interface PendingPatch {
|
||||||
|
install_status?: InstallStatus;
|
||||||
|
installed?: boolean;
|
||||||
|
status_message?: string;
|
||||||
|
status_level?: StatusLevel | undefined;
|
||||||
|
clearStatus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
||||||
|
let next: Game = { ...game };
|
||||||
|
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
||||||
|
if (patch.installed !== undefined) next.installed = patch.installed;
|
||||||
|
if (patch.clearStatus) {
|
||||||
|
next.status_message = undefined;
|
||||||
|
next.status_level = undefined;
|
||||||
|
}
|
||||||
|
if (patch.status_message !== undefined) {
|
||||||
|
next.status_message = patch.status_message;
|
||||||
|
next.status_level = patch.status_level;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the games list and reflects every backend event (download/install/
|
||||||
|
* uninstall lifecycle, peer count) into local React state. Returns a
|
||||||
|
* fire-and-forget `markChecking` helper so action calls can immediately show a
|
||||||
|
* "Checking peers…" state with an automatic fall-back if the backend never
|
||||||
|
* emits a follow-up event.
|
||||||
|
*/
|
||||||
|
export interface UseGamesResult {
|
||||||
|
games: Game[];
|
||||||
|
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
||||||
|
totalPeerCount: number;
|
||||||
|
requestGames: () => Promise<void>;
|
||||||
|
markChecking: (id: string) => void;
|
||||||
|
cancelChecking: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
||||||
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
|
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||||
|
const checkingTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
|
const rescanRef = useRef(rescanGameDir);
|
||||||
|
rescanRef.current = rescanGameDir;
|
||||||
|
|
||||||
|
const cancelChecking = useCallback((id: string) => {
|
||||||
|
const t = checkingTimeouts.current[id];
|
||||||
|
if (t !== undefined) {
|
||||||
|
clearTimeout(t);
|
||||||
|
delete checkingTimeouts.current[id];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markChecking = useCallback((id: string) => {
|
||||||
|
cancelChecking(id);
|
||||||
|
setGames(prev => prev.map(item =>
|
||||||
|
item.id === id
|
||||||
|
? { ...item, install_status: InstallStatus.CheckingPeers }
|
||||||
|
: item,
|
||||||
|
));
|
||||||
|
checkingTimeouts.current[id] = setTimeout(() => {
|
||||||
|
setGames(prev => prev.map(item => {
|
||||||
|
if (item.id !== id || item.install_status !== InstallStatus.CheckingPeers) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
install_status: item.installed
|
||||||
|
? InstallStatus.Installed
|
||||||
|
: InstallStatus.NotInstalled,
|
||||||
|
status_message: 'No peers currently have this game.',
|
||||||
|
status_level: 'error',
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
delete checkingTimeouts.current[id];
|
||||||
|
}, CHECKING_PEERS_TIMEOUT_MS);
|
||||||
|
}, [cancelChecking]);
|
||||||
|
|
||||||
|
const requestGames = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await invoke('request_games');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('request_games failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisteners: UnlistenFn[] = [];
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const updateById = (id: string, patch: PendingPatch) => {
|
||||||
|
setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleErrorEvent = (
|
||||||
|
id: string,
|
||||||
|
message: string,
|
||||||
|
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
cancelChecking(id);
|
||||||
|
setGames(prev => prev.map(item => item.id === id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
install_status: item.installed
|
||||||
|
? InstallStatus.Installed
|
||||||
|
: InstallStatus.NotInstalled,
|
||||||
|
status_message: message,
|
||||||
|
status_level: 'error',
|
||||||
|
}
|
||||||
|
: item));
|
||||||
|
if (triggerRescan) rescanRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
unlisteners.push(await listen('games-list-updated', (event) => {
|
||||||
|
const payload = normalizeGamesListPayload(
|
||||||
|
event.payload as GamesListPayload | Game[],
|
||||||
|
);
|
||||||
|
const activeStatuses = activeStatusById(payload.active_operations);
|
||||||
|
const hasAuthoritative = payload.active_operations !== undefined;
|
||||||
|
setGames(prev => {
|
||||||
|
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||||
|
return payload.games.map(game => mergeGameUpdate(
|
||||||
|
game,
|
||||||
|
previousById.get(game.id),
|
||||||
|
activeStatuses.get(game.id),
|
||||||
|
hasAuthoritative,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-download-begin', (e) => {
|
||||||
|
const id = e.payload as string;
|
||||||
|
cancelChecking(id);
|
||||||
|
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-download-finished', (e) => {
|
||||||
|
const id = e.payload as string;
|
||||||
|
cancelChecking(id);
|
||||||
|
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-download-failed', (e) => {
|
||||||
|
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
||||||
|
triggerRescan: true,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-download-peers-gone', (e) => {
|
||||||
|
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
|
||||||
|
triggerRescan: true,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-no-peers', (e) => {
|
||||||
|
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-install-begin', (e) => {
|
||||||
|
const id = e.payload as string;
|
||||||
|
cancelChecking(id);
|
||||||
|
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-install-finished', (e) => {
|
||||||
|
const id = e.payload as string;
|
||||||
|
cancelChecking(id);
|
||||||
|
updateById(id, {
|
||||||
|
install_status: InstallStatus.Installed,
|
||||||
|
installed: true,
|
||||||
|
clearStatus: true,
|
||||||
|
});
|
||||||
|
rescanRef.current();
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-install-failed', (e) => {
|
||||||
|
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-uninstall-begin', (e) => {
|
||||||
|
updateById(e.payload as string, {
|
||||||
|
install_status: InstallStatus.Uninstalling,
|
||||||
|
clearStatus: true,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-uninstall-finished', (e) => {
|
||||||
|
updateById(e.payload as string, {
|
||||||
|
install_status: InstallStatus.NotInstalled,
|
||||||
|
installed: false,
|
||||||
|
clearStatus: true,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
||||||
|
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
||||||
|
}));
|
||||||
|
|
||||||
|
unlisteners.push(await listen('peer-count-updated', (e) => {
|
||||||
|
setTotalPeerCount(e.payload as number);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
await invoke('request_games').catch(err =>
|
||||||
|
console.error('request_games failed:', err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to register game listeners:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void register();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisteners.forEach(fn => fn());
|
||||||
|
Object.values(checkingTimeouts.current).forEach(clearTimeout);
|
||||||
|
checkingTimeouts.current = {};
|
||||||
|
};
|
||||||
|
}, [cancelChecking]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
games,
|
||||||
|
setGames,
|
||||||
|
totalPeerCount,
|
||||||
|
requestGames,
|
||||||
|
markChecking,
|
||||||
|
cancelChecking,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { load } from '@tauri-apps/plugin-store';
|
||||||
|
|
||||||
|
import { GameFilter, GameSort } from '../lib/types';
|
||||||
|
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||||
|
|
||||||
|
export type Density = 'compact' | 'normal' | 'large';
|
||||||
|
export type CoverAspect = 'box' | 'square' | 'banner';
|
||||||
|
export type BackgroundStyle = 'flat' | 'gradient' | 'animated';
|
||||||
|
|
||||||
|
export interface UISettings {
|
||||||
|
accent: string;
|
||||||
|
bg: BackgroundStyle;
|
||||||
|
density: Density;
|
||||||
|
aspect: CoverAspect;
|
||||||
|
sort: GameSort;
|
||||||
|
filter: GameFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACCENT_OPTIONS = [
|
||||||
|
{ value: '#3b82f6', label: 'Blue' },
|
||||||
|
{ value: '#22d3ee', label: 'Cyan' },
|
||||||
|
{ value: '#a855f7', label: 'Violet' },
|
||||||
|
{ value: '#22c55e', label: 'Green' },
|
||||||
|
{ value: '#f59e0b', label: 'Amber' },
|
||||||
|
{ value: '#ef4444', label: 'Red' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BG_OPTIONS: ReadonlyArray<{ value: BackgroundStyle; label: string }> = [
|
||||||
|
{ value: 'flat', label: 'Flat' },
|
||||||
|
{ value: 'gradient', label: 'Gradient' },
|
||||||
|
{ value: 'animated', label: 'Animated' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DENSITY_OPTIONS: ReadonlyArray<{ value: Density; label: string }> = [
|
||||||
|
{ value: 'compact', label: 'Compact' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }> = [
|
||||||
|
{ value: 'box', label: 'Box-art' },
|
||||||
|
{ value: 'square', label: 'Square' },
|
||||||
|
{ value: 'banner', label: 'Banner' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: UISettings = {
|
||||||
|
accent: '#3b82f6',
|
||||||
|
bg: 'gradient',
|
||||||
|
density: 'normal',
|
||||||
|
aspect: 'box',
|
||||||
|
sort: 'status',
|
||||||
|
filter: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitize = (raw: Partial<UISettings> | undefined): UISettings => ({
|
||||||
|
accent: raw?.accent ?? DEFAULT_SETTINGS.accent,
|
||||||
|
bg: raw?.bg ?? DEFAULT_SETTINGS.bg,
|
||||||
|
density: raw?.density ?? DEFAULT_SETTINGS.density,
|
||||||
|
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
|
||||||
|
sort: raw?.sort ?? DEFAULT_SETTINGS.sort,
|
||||||
|
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface UseSettings {
|
||||||
|
settings: UISettings;
|
||||||
|
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads UI preferences from the Tauri persistent store once on mount and
|
||||||
|
* writes every change back through it. Components only see a synchronous
|
||||||
|
* `settings` snapshot; persistence is fire-and-forget.
|
||||||
|
*/
|
||||||
|
export const useSettings = (): UseSettings => {
|
||||||
|
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
const saved = await store.get<Partial<UISettings>>(UI_SETTINGS_KEY);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettings(sanitize(saved ?? undefined));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load UI settings:', err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void init();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
|
||||||
|
setSettings(prev => {
|
||||||
|
const next = { ...prev, [key]: value };
|
||||||
|
void persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { settings, set, ready };
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (settings: UISettings): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
await store.set(UI_SETTINGS_KEY, settings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist UI settings:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
|
||||||
|
* known; returns the empty string when the backend has nothing for the id, so
|
||||||
|
* callers can fall back to the placeholder cover art.
|
||||||
|
*/
|
||||||
|
export const useThumbnails = () => {
|
||||||
|
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
||||||
|
const pending = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const get = useCallback((id: string): string | null => {
|
||||||
|
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
|
||||||
|
if (pending.current.has(id)) return null;
|
||||||
|
pending.current.add(id);
|
||||||
|
invoke<string>('get_game_thumbnail', { gameId: id })
|
||||||
|
.then(url => {
|
||||||
|
pending.current.delete(id);
|
||||||
|
setThumbnails(prev => new Map(prev).set(id, url));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pending.current.delete(id);
|
||||||
|
setThumbnails(prev => new Map(prev).set(id, ''));
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [thumbnails]);
|
||||||
|
|
||||||
|
return { get };
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Stable gradient + accent derived from a game id. Used as cover-art fallback
|
||||||
|
* when the backend has no thumbnail for a game.
|
||||||
|
*/
|
||||||
|
export interface CoverColors {
|
||||||
|
c1: string;
|
||||||
|
c2: string;
|
||||||
|
accent: string;
|
||||||
|
angle: number;
|
||||||
|
blobX: number;
|
||||||
|
blobY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE: Array<Pick<CoverColors, 'c1' | 'c2' | 'accent'>> = [
|
||||||
|
{ c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24' },
|
||||||
|
{ c1: '#1e40af', c2: '#0c1f3a', accent: '#22d3ee' },
|
||||||
|
{ c1: '#15803d', c2: '#052e16', accent: '#fef08a' },
|
||||||
|
{ c1: '#7f1d1d', c2: '#0a0a0a', accent: '#f97316' },
|
||||||
|
{ c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b' },
|
||||||
|
{ c1: '#a16207', c2: '#422006', accent: '#fde047' },
|
||||||
|
{ c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee' },
|
||||||
|
{ c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee' },
|
||||||
|
{ c1: '#064e3b', c2: '#020617', accent: '#34d399' },
|
||||||
|
{ c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hash = (id: string): number => {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coverColorsFor = (id: string): CoverColors => {
|
||||||
|
const h = hash(id);
|
||||||
|
const base = PALETTE[h % PALETTE.length];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
angle: 110 + (h % 60),
|
||||||
|
blobX: 60 + (h % 30),
|
||||||
|
blobY: 10 + ((h * 7) % 30),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const titleFontSize = (
|
||||||
|
title: string,
|
||||||
|
aspect: 'box' | 'square' | 'banner',
|
||||||
|
): number => {
|
||||||
|
const len = title.length;
|
||||||
|
if (aspect === 'banner' || aspect === 'square') {
|
||||||
|
if (len > 22) return 18;
|
||||||
|
if (len > 14) return 22;
|
||||||
|
return 28;
|
||||||
|
}
|
||||||
|
if (len > 26) return 15;
|
||||||
|
if (len > 20) return 17;
|
||||||
|
if (len > 14) return 21;
|
||||||
|
return 26;
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const GB = 1024 * 1024 * 1024;
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
export const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`;
|
||||||
|
if (bytes >= MB) return `${(bytes / MB).toFixed(0)} MB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ETI version stamp (YYYYMMDD) for display. Falls back to the raw
|
||||||
|
* string when it doesn't fit the expected shape.
|
||||||
|
*/
|
||||||
|
export const formatEtiVersion = (raw: string | undefined): string => {
|
||||||
|
if (!raw) return '—';
|
||||||
|
if (raw.length === 8 && /^\d{8}$/.test(raw)) {
|
||||||
|
return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
|
||||||
|
export const truncatePath = (path: string, max = 36): string =>
|
||||||
|
path.length > max ? `…${path.slice(-(max - 1))}` : path;
|
||||||
|
|
||||||
|
export const formatPlayers = (max?: number): string => {
|
||||||
|
if (!max || max <= 0) return '—';
|
||||||
|
return max === 1 ? '1' : `1–${max}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {
|
||||||
|
ActiveOperation,
|
||||||
|
ActiveOperationKind,
|
||||||
|
DerivedState,
|
||||||
|
Game,
|
||||||
|
GameFilter,
|
||||||
|
GameSort,
|
||||||
|
GamesListPayload,
|
||||||
|
InstallStatus,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||||
|
InstallStatus.CheckingPeers,
|
||||||
|
InstallStatus.Downloading,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
InstallStatus.Uninstalling,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
||||||
|
InstallStatus.Downloading,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
InstallStatus.Uninstalling,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const isInProgress = (status: InstallStatus): boolean =>
|
||||||
|
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||||
|
|
||||||
|
const isReconciledOperationStatus = (status: InstallStatus): boolean =>
|
||||||
|
RECONCILED_OPERATION_STATUSES.has(status);
|
||||||
|
|
||||||
|
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
||||||
|
switch (op) {
|
||||||
|
case ActiveOperationKind.Downloading:
|
||||||
|
return InstallStatus.Downloading;
|
||||||
|
case ActiveOperationKind.Installing:
|
||||||
|
case ActiveOperationKind.Updating:
|
||||||
|
return InstallStatus.Installing;
|
||||||
|
case ActiveOperationKind.Uninstalling:
|
||||||
|
return InstallStatus.Uninstalling;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
|
||||||
|
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
|
||||||
|
|
||||||
|
export const normalizeGamesListPayload = (
|
||||||
|
payload: GamesListPayload | Game[],
|
||||||
|
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile a freshly received backend snapshot of a game with our prior
|
||||||
|
* locally-tracked install status. Keeps in-progress operations visible across
|
||||||
|
* snapshots that don't yet reflect the running operation.
|
||||||
|
*/
|
||||||
|
export const mergeGameUpdate = (
|
||||||
|
incoming: Game,
|
||||||
|
previous?: Game,
|
||||||
|
activeStatus?: InstallStatus,
|
||||||
|
hasAuthoritativeSnapshot = false,
|
||||||
|
): Game => {
|
||||||
|
let installStatus = InstallStatus.NotInstalled;
|
||||||
|
if (activeStatus !== undefined) {
|
||||||
|
installStatus = activeStatus;
|
||||||
|
} else if (incoming.installed) {
|
||||||
|
installStatus = InstallStatus.Installed;
|
||||||
|
} else if (
|
||||||
|
previous
|
||||||
|
&& isInProgress(previous.install_status)
|
||||||
|
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
||||||
|
) {
|
||||||
|
installStatus = previous.install_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStateChanged = previous !== undefined
|
||||||
|
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
||||||
|
const activeStateReconciled = hasAuthoritativeSnapshot
|
||||||
|
&& (activeStatus !== undefined
|
||||||
|
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
||||||
|
const clearStatus = localStateChanged || activeStateReconciled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...incoming,
|
||||||
|
availability: incoming.availability,
|
||||||
|
install_status: installStatus,
|
||||||
|
status_message: clearStatus ? undefined : previous?.status_message,
|
||||||
|
status_level: clearStatus ? undefined : previous?.status_level,
|
||||||
|
peer_count: incoming.peer_count ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Visual card state — used for state chip color and action button styling. */
|
||||||
|
export const deriveState = (game: Game): DerivedState => {
|
||||||
|
if (isInProgress(game.install_status)) return 'busy';
|
||||||
|
if (game.installed) return 'installed';
|
||||||
|
if (game.downloaded) return 'local';
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isUnavailable = (game: Game): boolean =>
|
||||||
|
!game.installed
|
||||||
|
&& !game.downloaded
|
||||||
|
&& game.peer_count === 0
|
||||||
|
&& game.install_status === InstallStatus.NotInstalled;
|
||||||
|
|
||||||
|
export const needsUpdate = (game: Game): boolean => {
|
||||||
|
if (!game.installed) return false;
|
||||||
|
const peer = game.eti_game_version;
|
||||||
|
const local = game.local_version;
|
||||||
|
if (!local && peer) return true;
|
||||||
|
if (local && peer) {
|
||||||
|
const l = parseInt(local, 10);
|
||||||
|
const p = parseInt(peer, 10);
|
||||||
|
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What pressing the card's main action button should do, given the state. */
|
||||||
|
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
||||||
|
|
||||||
|
export const primaryActionFor = (game: Game): PrimaryAction => {
|
||||||
|
if (isInProgress(game.install_status)) return 'busy';
|
||||||
|
if (isUnavailable(game)) return 'disabled';
|
||||||
|
if (!game.installed) return game.downloaded ? 'install' : 'download';
|
||||||
|
if (needsUpdate(game)) return 'update';
|
||||||
|
return 'play';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inProgressLabel = (status: InstallStatus): string | undefined => {
|
||||||
|
switch (status) {
|
||||||
|
case InstallStatus.CheckingPeers:
|
||||||
|
return 'Checking peers…';
|
||||||
|
case InstallStatus.Downloading:
|
||||||
|
return 'Downloading…';
|
||||||
|
case InstallStatus.Installing:
|
||||||
|
return 'Installing…';
|
||||||
|
case InstallStatus.Uninstalling:
|
||||||
|
return 'Uninstalling…';
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionLabel = (game: Game): string => {
|
||||||
|
const busy = inProgressLabel(game.install_status);
|
||||||
|
if (busy) return busy;
|
||||||
|
if (isUnavailable(game)) return 'Unavailable';
|
||||||
|
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
|
||||||
|
if (needsUpdate(game)) return 'Update';
|
||||||
|
return 'Play';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Counts shown on filter pills. */
|
||||||
|
export interface FilterCounts {
|
||||||
|
all: number;
|
||||||
|
local: number;
|
||||||
|
installed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||||
|
all: games.length,
|
||||||
|
local: games.filter(g => g.installed || g.downloaded).length,
|
||||||
|
installed: games.filter(g => g.installed).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'local':
|
||||||
|
return game.installed || game.downloaded;
|
||||||
|
case 'installed':
|
||||||
|
return game.installed;
|
||||||
|
case 'all':
|
||||||
|
return game.installed || game.downloaded || game.peer_count > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||||
|
busy: 0,
|
||||||
|
installed: 1,
|
||||||
|
local: 2,
|
||||||
|
none: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareByState = (a: Game, b: Game): number => {
|
||||||
|
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
|
||||||
|
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyFilterAndSort = (
|
||||||
|
games: Game[],
|
||||||
|
filter: GameFilter,
|
||||||
|
sort: GameSort,
|
||||||
|
query: string,
|
||||||
|
): Game[] => {
|
||||||
|
let list = games.filter(g => matchesFilter(g, filter));
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
list = list.filter(g =>
|
||||||
|
g.name.toLowerCase().includes(q)
|
||||||
|
|| (g.genre?.toLowerCase().includes(q) ?? false)
|
||||||
|
|| (g.publisher?.toLowerCase().includes(q) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (sort) {
|
||||||
|
case 'az':
|
||||||
|
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
case 'size':
|
||||||
|
return [...list].sort((a, b) => b.size - a.size);
|
||||||
|
case 'status':
|
||||||
|
return [...list].sort(compareByState);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** File names + keys for the @tauri-apps/plugin-store-backed persistent state. */
|
||||||
|
|
||||||
|
export const SETTINGS_FILE = 'launcher-settings.json';
|
||||||
|
export const GAME_DIR_KEY = 'game-directory';
|
||||||
|
export const UI_SETTINGS_KEY = 'ui-settings';
|
||||||
|
|
||||||
|
export const SETTINGS_FILE_OPTIONS = {
|
||||||
|
autoSave: true,
|
||||||
|
defaults: {
|
||||||
|
[GAME_DIR_KEY]: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export enum InstallStatus {
|
||||||
|
NotInstalled = 'NotInstalled',
|
||||||
|
CheckingPeers = 'CheckingPeers',
|
||||||
|
Downloading = 'Downloading',
|
||||||
|
Installing = 'Installing',
|
||||||
|
Uninstalling = 'Uninstalling',
|
||||||
|
Installed = 'Installed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GameAvailability {
|
||||||
|
Ready = 'Ready',
|
||||||
|
LocalOnly = 'LocalOnly',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActiveOperationKind {
|
||||||
|
Downloading = 'Downloading',
|
||||||
|
Installing = 'Installing',
|
||||||
|
Updating = 'Updating',
|
||||||
|
Uninstalling = 'Uninstalling',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusLevel = 'info' | 'error';
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** Bytes. */
|
||||||
|
size: number;
|
||||||
|
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
||||||
|
thumbnail?: Uint8Array | number[];
|
||||||
|
downloaded: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
availability: GameAvailability;
|
||||||
|
install_status: InstallStatus;
|
||||||
|
eti_game_version?: string;
|
||||||
|
local_version?: string;
|
||||||
|
/** Optional richer metadata surfaced by the backend. */
|
||||||
|
release_year?: string;
|
||||||
|
publisher?: string;
|
||||||
|
max_players?: number;
|
||||||
|
version?: string;
|
||||||
|
genre?: string;
|
||||||
|
status_message?: string;
|
||||||
|
status_level?: StatusLevel;
|
||||||
|
peer_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveOperation {
|
||||||
|
id: string;
|
||||||
|
operation: ActiveOperationKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamesListPayload {
|
||||||
|
games: Game[];
|
||||||
|
active_operations?: ActiveOperation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Library filter chip — what subset of the catalog to show. */
|
||||||
|
export type GameFilter = 'all' | 'local' | 'installed';
|
||||||
|
|
||||||
|
/** Library sort order. */
|
||||||
|
export type GameSort = 'az' | 'size' | 'status';
|
||||||
|
|
||||||
|
/** Visual state of a card. Derived from install/download flags. */
|
||||||
|
export type DerivedState = 'installed' | 'local' | 'none' | 'busy';
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import "./styles/tokens.css";
|
||||||
|
import "./styles/launcher.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -0,0 +1,1249 @@
|
|||||||
|
/* Launcher root */
|
||||||
|
.launcher {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--t-1);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-flat {
|
||||||
|
background: var(--bg-0);
|
||||||
|
}
|
||||||
|
.bg-gradient {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 80% 50% at 50% -10%,
|
||||||
|
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
|
}
|
||||||
|
.bg-animated {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 60% 40% at 20% 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
|
||||||
|
transparent 55%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 55% 40% at 85% 8%,
|
||||||
|
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
|
||||||
|
transparent 55%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
animation: bgshift 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes bgshift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%, 0% 0%, 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 10% 4%, -6% 2%, 0% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(10, 14, 19, 0.65);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--accent);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: white;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer count chip in brand area */
|
||||||
|
.brand-peers {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.brand-peers-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 6px var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented filters */
|
||||||
|
.seg {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition:
|
||||||
|
left 0.22s cubic-bezier(0.4, 1.2, 0.5, 1),
|
||||||
|
width 0.22s cubic-bezier(0.4, 1.2, 0.5, 1);
|
||||||
|
box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, black);
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.seg-btn:hover {
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.seg-btn.is-active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.seg-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.seg-btn.is-active .seg-count {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 320px;
|
||||||
|
flex: 0 1 380px;
|
||||||
|
color: var(--t-3);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
|
||||||
|
background: var(--bg-1);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.search input::placeholder {
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.search-kbd {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.search:focus-within .search-kbd {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort menu */
|
||||||
|
.sort {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sort-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.sort-btn:hover {
|
||||||
|
color: var(--t-1);
|
||||||
|
border-color: var(--bd-2);
|
||||||
|
}
|
||||||
|
.sort-btn strong {
|
||||||
|
color: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sort-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 40px -8px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.sort-menu button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-menu button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.sort-check {
|
||||||
|
width: 14px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directory button */
|
||||||
|
.dirbtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 360px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dirbtn:hover {
|
||||||
|
border-color: var(--bd-2);
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.dirbtn-label {
|
||||||
|
color: var(--t-1);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dirbtn-path {
|
||||||
|
color: var(--t-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kebab menu */
|
||||||
|
.kebab {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.kebab-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.kebab-btn:hover {
|
||||||
|
color: var(--t-1);
|
||||||
|
border-color: var(--bd-2);
|
||||||
|
}
|
||||||
|
.kebab-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.kebab-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kebab-menu button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.kebab-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bd-1);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid wrapper / results bar */
|
||||||
|
.grid-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px 24px 32px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bd-3) transparent;
|
||||||
|
}
|
||||||
|
.grid-wrap::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
.grid-wrap::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bd-3);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 4px 16px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.results-count {
|
||||||
|
color: var(--t-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.results-count strong {
|
||||||
|
color: var(--t-1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--card-gap, 16px);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 188px), 1fr));
|
||||||
|
}
|
||||||
|
.density-compact {
|
||||||
|
--card-min: 148px;
|
||||||
|
--card-gap: 12px;
|
||||||
|
}
|
||||||
|
.density-normal {
|
||||||
|
--card-min: 188px;
|
||||||
|
--card-gap: 16px;
|
||||||
|
}
|
||||||
|
.density-large {
|
||||||
|
--card-min: 244px;
|
||||||
|
--card-gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
transform 0.18s cubic-bezier(0.4, 1.2, 0.5, 1),
|
||||||
|
border-color 0.18s,
|
||||||
|
box-shadow 0.18s;
|
||||||
|
outline: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.card:hover,
|
||||||
|
.card:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||||
|
box-shadow:
|
||||||
|
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
.card:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
|
||||||
|
0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.card-cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
.card-cover-wrap[data-aspect="box"] {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
.card-cover-wrap[data-aspect="square"] {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
.card-cover-wrap[data-aspect="banner"] {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.4, 1.2, 0.5, 1);
|
||||||
|
}
|
||||||
|
.card:hover .cover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
.cover-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cover-base,
|
||||||
|
.cover-blob,
|
||||||
|
.cover-grain,
|
||||||
|
.cover-vignette,
|
||||||
|
.cover-mark {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cover-grain {
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(255, 255, 255, 0.018) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 0, 0, 0.04) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.cover-vignette {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 30%,
|
||||||
|
rgba(0, 0, 0, 0.62) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.cover-mark {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.cover-titlewrap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.cover-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.018em;
|
||||||
|
line-height: 1.02;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: white;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State chip */
|
||||||
|
.state-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8, 12, 16, 0.78);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.state-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.state-chip[data-state="installed"] .state-dot {
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 8px var(--ok);
|
||||||
|
}
|
||||||
|
.state-chip[data-state="local"] .state-dot {
|
||||||
|
background: var(--warn);
|
||||||
|
box-shadow: 0 0 8px var(--warn);
|
||||||
|
}
|
||||||
|
.state-chip[data-state="busy"] .state-dot {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px var(--accent);
|
||||||
|
animation: state-busy 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes state-busy {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multiplayer / peers badge */
|
||||||
|
.card-mp {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8, 12, 16, 0.65);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card body */
|
||||||
|
.card-body {
|
||||||
|
padding: 11px 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--t-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-meta .card-dot {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.card-status {
|
||||||
|
min-height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.card-status.is-error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.density-compact .card-body {
|
||||||
|
padding: 9px 10px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.density-compact .card-title {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.density-compact .card-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.density-large .card-body {
|
||||||
|
padding: 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.density-large .card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.act-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.12s,
|
||||||
|
filter 0.12s,
|
||||||
|
background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.act-btn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
.act-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.act-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.act-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.act-lg {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.act-lg svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act-play {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(180deg, #2bd07f 0%, #1aa460 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px -8px #1aa460,
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.act-install,
|
||||||
|
.act-update {
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px -8px color-mix(in srgb, var(--accent) 80%, black),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
.act-download {
|
||||||
|
color: var(--t-1);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.act-download:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: var(--bd-3);
|
||||||
|
}
|
||||||
|
.act-busy {
|
||||||
|
color: var(--t-1);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
.act-busy::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1.6px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.act-disabled {
|
||||||
|
color: var(--t-3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost / secondary buttons */
|
||||||
|
.ghost-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--bd-3);
|
||||||
|
}
|
||||||
|
.ghost-danger {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.ghost-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(4, 7, 11, 0.7);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
animation: fadein 0.18s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadein {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(880px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px -10px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: modalin 0.25s cubic-bezier(0.3, 1.3, 0.4, 1);
|
||||||
|
}
|
||||||
|
@keyframes modalin {
|
||||||
|
from {
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 5;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(8, 12, 16, 0.7);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-1);
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: var(--bd-3);
|
||||||
|
}
|
||||||
|
.modal-hero {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
.modal-hero .cover {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
.modal-hero-fade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, transparent 40%, var(--bg-2) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.modal-hero-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 28px;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 22px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.modal-hero-text .modal-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: white;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.modal-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(8, 12, 16, 0.6);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.modal-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 24px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.modal-state .state-chip {
|
||||||
|
position: static;
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 22px 28px 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.modal-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.meta-cell {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.meta-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--t-2);
|
||||||
|
text-wrap: pretty;
|
||||||
|
max-width: 64ch;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.modal-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t-2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.modal-status.is-error {
|
||||||
|
color: #f87171;
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-actions-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings dialog */
|
||||||
|
.settings-modal {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.settings-head {
|
||||||
|
position: relative;
|
||||||
|
padding: 22px 28px 18px;
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
.settings-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.settings-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
}
|
||||||
|
.settings-body {
|
||||||
|
padding: 22px 28px 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 26px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.settings-row-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-row-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.settings-row-hint {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.settings-row-control {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 14px 22px 18px;
|
||||||
|
border-top: 1px solid var(--bd-1);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.settings-done {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 22px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.12s;
|
||||||
|
}
|
||||||
|
.settings-done:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings: color swatches */
|
||||||
|
.swatch-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.swatch {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.swatch-dot {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.swatch:hover .swatch-dot {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
.swatch.is-active .swatch-dot {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--bg-2),
|
||||||
|
0 0 0 4px currentColor;
|
||||||
|
}
|
||||||
|
.swatch-check {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings: segmented radio */
|
||||||
|
.srad {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.srad-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.srad-btn:hover {
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.srad-btn.is-active {
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px -2px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / placeholder states */
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--t-2);
|
||||||
|
}
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
color: var(--t-2);
|
||||||
|
}
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--t-1);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.empty-state-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--t-3);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
max-width: 44ch;
|
||||||
|
}
|
||||||
|
.empty-state .ghost-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.empty-state .ghost-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
:root {
|
||||||
|
--accent: #3b82f6;
|
||||||
|
|
||||||
|
--bg-0: #0a0e13;
|
||||||
|
--bg-1: #0f151c;
|
||||||
|
--bg-2: #131b25;
|
||||||
|
--bg-3: #1a2330;
|
||||||
|
--bg-4: #232f3e;
|
||||||
|
|
||||||
|
--bd-1: rgba(255, 255, 255, 0.06);
|
||||||
|
--bd-2: rgba(255, 255, 255, 0.10);
|
||||||
|
--bd-3: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
--t-1: #e6edf3;
|
||||||
|
--t-2: #9aa6b4;
|
||||||
|
--t-3: #6b7785;
|
||||||
|
--t-4: #4a5663;
|
||||||
|
|
||||||
|
--ok: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
|
||||||
|
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable",
|
||||||
|
"Segoe UI", Inter, system-ui, sans-serif;
|
||||||
|
--font-display: "Bebas Neue", "Oswald", Impact, "Arial Narrow Bold",
|
||||||
|
sans-serif;
|
||||||
|
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--t-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
|
||||||
|
import { TopBar } from '../components/topbar/TopBar';
|
||||||
|
import { KebabItem } from '../components/topbar/KebabMenu';
|
||||||
|
import { ResultsBar } from '../components/grid/ResultsBar';
|
||||||
|
import { GameGrid } from '../components/grid/GameGrid';
|
||||||
|
import { GameDetailModal } from '../components/modals/GameDetailModal';
|
||||||
|
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
||||||
|
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
||||||
|
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
||||||
|
|
||||||
|
import { useGameDirectory } from '../hooks/useGameDirectory';
|
||||||
|
import { useGames } from '../hooks/useGames';
|
||||||
|
import { useGameActions } from '../hooks/useGameActions';
|
||||||
|
import { useThumbnails } from '../hooks/useThumbnails';
|
||||||
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
|
||||||
|
import { Game } from '../lib/types';
|
||||||
|
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
||||||
|
|
||||||
|
const openLogsWindow = async () => {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||||
|
try {
|
||||||
|
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = new WebviewWindow('unpack-logs', {
|
||||||
|
url: '/?view=unpack-logs',
|
||||||
|
title: 'Unpack Logs',
|
||||||
|
width: 900,
|
||||||
|
height: 700,
|
||||||
|
resizable: true,
|
||||||
|
});
|
||||||
|
await win.once<unknown>('tauri://error', (event) => {
|
||||||
|
console.error('Error opening unpack logs window:', event.payload);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error opening unpack logs window:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainWindow = () => {
|
||||||
|
const { settings, set: setSetting } = useSettings();
|
||||||
|
const { gameDir, setGameDir, rescan } = useGameDirectory();
|
||||||
|
const games = useGames(rescan);
|
||||||
|
const actions = useGameActions(games);
|
||||||
|
const thumbnails = useThumbnails();
|
||||||
|
|
||||||
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const counts = useMemo(() => countByFilter(games.games), [games.games]);
|
||||||
|
|
||||||
|
// Query is local UI state (no need to persist).
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const filteredGames = useMemo(
|
||||||
|
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
|
||||||
|
[games.games, settings.filter, settings.sort, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openGame = useMemo<Game | null>(
|
||||||
|
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
|
||||||
|
[openGameId, games.games],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickDirectory = useCallback(async () => {
|
||||||
|
const picked = await open({ multiple: false, directory: true });
|
||||||
|
if (typeof picked === 'string' && picked) setGameDir(picked);
|
||||||
|
}, [setGameDir]);
|
||||||
|
|
||||||
|
const handlePrimary = useCallback((game: Game) => {
|
||||||
|
if (game.installed) {
|
||||||
|
if (needsUpdate(game)) actions.update(game.id);
|
||||||
|
else actions.play(game.id);
|
||||||
|
} else {
|
||||||
|
actions.install(game.id);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const handleUninstall = useCallback((game: Game) => {
|
||||||
|
actions.uninstall(game.id);
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||||
|
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
|
||||||
|
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
|
||||||
|
{ kind: 'separator' },
|
||||||
|
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
|
||||||
|
], [rescan]);
|
||||||
|
|
||||||
|
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
|
||||||
|
const className = [
|
||||||
|
'launcher',
|
||||||
|
`bg-${settings.bg}`,
|
||||||
|
`density-${settings.density}`,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={rootStyle}>
|
||||||
|
{gameDir ? (
|
||||||
|
<>
|
||||||
|
<TopBar
|
||||||
|
peerCount={games.totalPeerCount}
|
||||||
|
filter={settings.filter}
|
||||||
|
setFilter={(v) => setSetting('filter', v)}
|
||||||
|
counts={counts}
|
||||||
|
query={query}
|
||||||
|
setQuery={setQuery}
|
||||||
|
sort={settings.sort}
|
||||||
|
setSort={(v) => setSetting('sort', v)}
|
||||||
|
gameDir={gameDir}
|
||||||
|
onPickDirectory={() => void pickDirectory()}
|
||||||
|
kebabItems={kebabItems}
|
||||||
|
/>
|
||||||
|
<main className="grid-wrap">
|
||||||
|
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
games.games.length === 0 ? (
|
||||||
|
<EmptyResultsState
|
||||||
|
title="Scanning for games"
|
||||||
|
hint="Looking for game bundles in your selected directory…"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyResultsState
|
||||||
|
title="Nothing matches"
|
||||||
|
hint="No games match the current filter or search query."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<GameGrid
|
||||||
|
games={filteredGames}
|
||||||
|
aspect={settings.aspect}
|
||||||
|
getThumbnail={thumbnails.get}
|
||||||
|
onOpen={(g) => setOpenGameId(g.id)}
|
||||||
|
onPrimary={handlePrimary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<main className="grid-wrap">
|
||||||
|
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openGame && (
|
||||||
|
<GameDetailModal
|
||||||
|
game={openGame}
|
||||||
|
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||||
|
onClose={() => setOpenGameId(null)}
|
||||||
|
onPrimary={handlePrimary}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onChange={setSetting}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
# Handoff: SoftLAN Launcher redesign
|
||||||
|
|
||||||
|
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
|
||||||
|
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
|
||||||
|
|
||||||
|
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
|
||||||
|
|
||||||
|
- Exact pixel/spacing/color/typography values
|
||||||
|
- Component composition and interactions
|
||||||
|
- Copy and microcopy
|
||||||
|
- Animation easings/durations
|
||||||
|
|
||||||
|
But:
|
||||||
|
|
||||||
|
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
|
||||||
|
- Don't keep the `<deck>` / design-canvas wrapping — that's only for presenting variants
|
||||||
|
- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below)
|
||||||
|
- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).
|
||||||
|
|
||||||
|
## Layout variants
|
||||||
|
|
||||||
|
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screens / views
|
||||||
|
|
||||||
|
### 1. Main library (variant A — primary)
|
||||||
|
|
||||||
|
The default screen. A grid of game cards over a dark, gradient-tinted background.
|
||||||
|
|
||||||
|
**Layout (top-to-bottom):**
|
||||||
|
|
||||||
|
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Contents, left-to-right with 18px gap and 24px horizontal padding:
|
||||||
|
|
||||||
|
- **Brand** — 28×28px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20px white. Next to it, the wordmark "SoftLAN" in 15px / 700 weight `--t-1` `#e6edf3`.
|
||||||
|
- **Segmented filter pills** — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
||||||
|
- `All Games` · count chip
|
||||||
|
- `Local` · count chip
|
||||||
|
- `Installed` · count chip
|
||||||
|
|
||||||
|
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
||||||
|
|
||||||
|
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
||||||
|
- **Search field** — 36px tall, min-width 320px (flex 0 1 380px). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Has a leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border becomes `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The "/" key shortcut should focus the search.
|
||||||
|
- **Sort menu** — 36px button, same surface style as search. Label `Sort: <bold value>` plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`.
|
||||||
|
- **Game directory button** — 36px button, max-width 360px. Folder icon, "Game directory" label (600 weight `--t-1`), then the current path in `ui-monospace` 11.5px `--t-3` truncated with leading ellipsis when long (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`).
|
||||||
|
- **Kebab menu** (`⋮`) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog — see below), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`.
|
||||||
|
|
||||||
|
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
|
||||||
|
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
|
||||||
|
- Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: `<sq> 78 GB installed <sq> 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`.
|
||||||
|
|
||||||
|
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.
|
||||||
|
|
||||||
|
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20.
|
||||||
|
|
||||||
|
**Game card** (see "Game card" below for full anatomy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Game detail overlay
|
||||||
|
|
||||||
|
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
|
||||||
|
|
||||||
|
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
|
||||||
|
|
||||||
|
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
|
||||||
|
|
||||||
|
**Modal structure (top-to-bottom):**
|
||||||
|
|
||||||
|
1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
|
||||||
|
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
|
||||||
|
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
|
||||||
|
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
|
||||||
|
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
|
||||||
|
- **Title** as `<h2>` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Settings dialog
|
||||||
|
|
||||||
|
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ APPEARANCE │ ← section title: 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
|
||||||
|
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
|
||||||
|
│ │
|
||||||
|
│ Background │
|
||||||
|
│ Backdrop behind the library │
|
||||||
|
│ [Flat │Gradient│Animat]│ ← segmented radio
|
||||||
|
│ │
|
||||||
|
│ LIBRARY │
|
||||||
|
│ │
|
||||||
|
│ Grid density │
|
||||||
|
│ How tightly cards are packed │
|
||||||
|
│ [Compact│Normal│Large]│
|
||||||
|
│ │
|
||||||
|
│ Cover aspect │
|
||||||
|
│ Shape of the cover art on each card │
|
||||||
|
│ [Box-art│Square│Banner│
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [Done] │ ← foot: 14 22 18, 1px top border
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
|
||||||
|
|
||||||
|
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
|
||||||
|
- Right (`settings-row-control`): the control
|
||||||
|
|
||||||
|
**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`.
|
||||||
|
|
||||||
|
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
|
||||||
|
|
||||||
|
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
|
||||||
|
|
||||||
|
Persisted settings (write through to local storage / Tauri config):
|
||||||
|
- `accent`: one of the six hex values above. Default `#3b82f6`.
|
||||||
|
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||||
|
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||||
|
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game card
|
||||||
|
|
||||||
|
The unit element of the library grid.
|
||||||
|
|
||||||
|
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
|
||||||
|
|
||||||
|
**Hover/focus state:**
|
||||||
|
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
|
||||||
|
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
|
||||||
|
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
|
||||||
|
- Cover inner image scales to 1.03 (350ms cubic-bezier)
|
||||||
|
- Focus-visible: same lift + 2px solid accent outline
|
||||||
|
|
||||||
|
### Anatomy (top to bottom)
|
||||||
|
|
||||||
|
1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
|
||||||
|
|
||||||
|
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
|
||||||
|
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
|
||||||
|
- **Radial accent blob** — `radial-gradient(ellipse at <x>% <y>%, <accent>38, transparent 55%)`. x/y also hashed from id.
|
||||||
|
- **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7.
|
||||||
|
- **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash.
|
||||||
|
- **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px <c2 + alpha>, 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants).
|
||||||
|
- **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2).
|
||||||
|
- **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px <color>`.
|
||||||
|
- **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `2–32`). Always visible — every LAN game is multiplayer.
|
||||||
|
|
||||||
|
3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap:
|
||||||
|
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
|
||||||
|
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
|
||||||
|
- **Action button** (full width) — primary action depending on state, see below.
|
||||||
|
|
||||||
|
### Action button
|
||||||
|
|
||||||
|
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
|
||||||
|
|
||||||
|
```
|
||||||
|
state label button style
|
||||||
|
───────────── ────────── ────────────────────────────────────────────
|
||||||
|
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
|
||||||
|
local Install bg var(--accent), text white, inset top hl
|
||||||
|
installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
|
||||||
|
```
|
||||||
|
|
||||||
|
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
|
||||||
|
|
||||||
|
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
|
||||||
|
|
||||||
|
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter controls — variant B (not used, kept for reference)
|
||||||
|
|
||||||
|
The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs:
|
||||||
|
|
||||||
|
- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active.
|
||||||
|
- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`.
|
||||||
|
- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier).
|
||||||
|
|
||||||
|
Implement only if you decide variant A doesn't work after building.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactions & behavior
|
||||||
|
|
||||||
|
- **Click game card** (anywhere except the action button) → open detail overlay.
|
||||||
|
- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button.
|
||||||
|
- **Press / (slash)** → focus the search input.
|
||||||
|
- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring).
|
||||||
|
- **Click filter tab / segmented pill** → change filter.
|
||||||
|
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
|
||||||
|
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
|
||||||
|
- **Click "Game directory" button** → open native folder picker via Tauri; on selection, rescan library.
|
||||||
|
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
|
||||||
|
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
||||||
|
- **Click "Refresh library"** in kebab → re-runs the library scan.
|
||||||
|
- **Esc** → closes any open modal (detail overlay, Settings).
|
||||||
|
|
||||||
|
### Transitions / animations
|
||||||
|
|
||||||
|
- Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale.
|
||||||
|
- Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`.
|
||||||
|
- Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`.
|
||||||
|
- Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`.
|
||||||
|
- Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State management
|
||||||
|
|
||||||
|
Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.
|
||||||
|
|
||||||
|
**Library state** (rebuilt on `refresh`):
|
||||||
|
```ts
|
||||||
|
type Game = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size: number; // GB
|
||||||
|
version: string; // "YYYY.MM.DD"
|
||||||
|
desc: string;
|
||||||
|
state: 'installed' | 'local' | 'none';
|
||||||
|
players: string; // e.g. "2–32"
|
||||||
|
tags: string[];
|
||||||
|
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI state:**
|
||||||
|
```ts
|
||||||
|
type LauncherUI = {
|
||||||
|
filter: 'all' | 'local' | 'installed';
|
||||||
|
sort: 'az' | 'size' | 'recent' | 'state';
|
||||||
|
query: string;
|
||||||
|
openGameId: string | null;
|
||||||
|
settingsOpen: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Persisted settings:** see Settings dialog section. Persist via Tauri's plugin-store or a local JSON file in app data dir.
|
||||||
|
|
||||||
|
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
| token | value | usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg-0` | `#0a0e13` | launcher background |
|
||||||
|
| `--bg-1` | `#0f151c` | card bottom gradient stop |
|
||||||
|
| `--bg-2` | `#131b25` | top bar / card top / search bg |
|
||||||
|
| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback |
|
||||||
|
| `--bg-4` | `#232f3e` | (reserved) |
|
||||||
|
| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border |
|
||||||
|
| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border |
|
||||||
|
| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb |
|
||||||
|
| `--t-1` | `#e6edf3` | primary text |
|
||||||
|
| `--t-2` | `#9aa6b4` | secondary text |
|
||||||
|
| `--t-3` | `#6b7785` | muted text / metadata |
|
||||||
|
| `--t-4` | `#4a5663` | (reserved) |
|
||||||
|
| `--ok` | `#22c55e` | "installed" dot |
|
||||||
|
| `--warn` | `#f59e0b` | "local" dot |
|
||||||
|
| `--danger` | `#ef4444` | destructive actions |
|
||||||
|
| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif`
|
||||||
|
- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif`
|
||||||
|
- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay)
|
||||||
|
|
||||||
|
Sizing reference:
|
||||||
|
- Brand wordmark: 15 / 700
|
||||||
|
- Modal title: 32 / 700 / -0.015em
|
||||||
|
- Card title: 13.5 / 600
|
||||||
|
- Filter pill label: 12.5 / 600
|
||||||
|
- Settings row label: 14 / 600
|
||||||
|
- Section title (settings): 10.5 / 700 / 0.12em uppercase
|
||||||
|
- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3`
|
||||||
|
- Hint text: 12 / `--t-3`
|
||||||
|
|
||||||
|
### Spacing & radii
|
||||||
|
|
||||||
|
- Card radius: 10px
|
||||||
|
- Modal radius: 14px
|
||||||
|
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
|
||||||
|
- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
|
||||||
|
- Card body padding: 11 12 12
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
|
||||||
|
- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
|
||||||
|
- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)`
|
||||||
|
- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)`
|
||||||
|
- Action button (filled): `0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Fonts to load:
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File reference
|
||||||
|
|
||||||
|
```
|
||||||
|
design_reference/
|
||||||
|
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
|
||||||
|
├── styles.css ← all visual styles (CSS custom props + components)
|
||||||
|
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
|
||||||
|
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
|
||||||
|
│ SegmentedFilters, UnderlineFilters, SearchField,
|
||||||
|
│ SortMenu, StorageMeter, DirectoryButton, KebabMenu,
|
||||||
|
│ GameDetailModal, SettingsDialog
|
||||||
|
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope / open questions for the developer
|
||||||
|
|
||||||
|
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
|
||||||
|
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
|
||||||
|
- **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** — when a game is actively downloading or installing, the action button should become a progress bar with a cancel affordance. Not designed; recommend: replace the button with a progress bar of the same dimensions, percentage text on the left, cancel "×" on the right.
|
||||||
|
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SoftLAN Launcher — Redesign</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; background: #f0eee9; height: 100%; }
|
||||||
|
/* Tighten canvas chrome a hair */
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||||
|
#root { width: 100%; height: 100%; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="data.jsx"></script>
|
||||||
|
<script type="text/babel" src="components.jsx"></script>
|
||||||
|
<script type="text/babel" src="launcher.jsx"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"accent": "#3b82f6",
|
||||||
|
"density": "normal",
|
||||||
|
"aspect": "square",
|
||||||
|
"bg": "gradient"
|
||||||
|
}/*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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<DesignCanvas>
|
||||||
|
<DCSection id="chrome" title="Chrome variations"
|
||||||
|
subtitle="Two ways to organize the top bar — pick whichever density of controls you prefer. Click any card to open the detail overlay, or the kebab menu to open Settings.">
|
||||||
|
<DCArtboard id="single-row" label="A · Single-row + segmented filters" width={1340} height={840}>
|
||||||
|
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||||
|
initialFilter="all" initialSort="recent"/>
|
||||||
|
</DCArtboard>
|
||||||
|
|
||||||
|
<DCArtboard id="two-row" label="B · Two-row + underlined tabs" width={1340} height={840}>
|
||||||
|
<Launcher variant="two" tweaks={t} setTweak={setTweak}
|
||||||
|
initialFilter="installed" initialSort="az"/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||||
|
initialFilter="installed" initialSort="az"
|
||||||
|
initialOpenGame={heroGame}/>
|
||||||
|
</DCArtboard>
|
||||||
|
<DCArtboard id="detail-modal-local" label="D · Detail overlay (downloaded, not installed)" width={1340} height={840}>
|
||||||
|
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||||
|
initialFilter="local" initialSort="az"
|
||||||
|
initialOpenGame={GAMES.find(g => g.id === 'cod4mw')}/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
<DCSection id="settings" title="Settings dialog"
|
||||||
|
subtitle="Same controls as the dev Tweaks panel, surfaced as an in-app preferences dialog. Open via top-bar menu → Settings.">
|
||||||
|
<DCArtboard id="settings-open" label="E · Settings dialog (open)" width={1340} height={840}>
|
||||||
|
<Launcher variant="single" tweaks={t} setTweak={setTweak}
|
||||||
|
initialFilter="all" initialSort="recent"
|
||||||
|
initialSettingsOpen={true}/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
</DesignCanvas>
|
||||||
|
|
||||||
|
<TweaksPanel>
|
||||||
|
<TweakSection label="Theme"/>
|
||||||
|
<TweakColor label="Accent" value={t.accent} options={ACCENTS}
|
||||||
|
onChange={(v) => setTweak('accent', v)}/>
|
||||||
|
<TweakRadio label="Background" value={t.bg}
|
||||||
|
options={['flat', 'gradient', 'animated']}
|
||||||
|
onChange={(v) => setTweak('bg', v)}/>
|
||||||
|
|
||||||
|
<TweakSection label="Grid"/>
|
||||||
|
<TweakRadio label="Density" value={t.density}
|
||||||
|
options={['compact', 'normal', 'large']}
|
||||||
|
onChange={(v) => setTweak('density', v)}/>
|
||||||
|
<TweakRadio label="Cover aspect" value={t.aspect}
|
||||||
|
options={['box', 'square', 'banner']}
|
||||||
|
onChange={(v) => setTweak('aspect', v)}/>
|
||||||
|
</TweaksPanel>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
// components.jsx — UI building blocks for the SoftLAN launcher
|
||||||
|
// Loaded after data.jsx; relies on GAMES/STATE_META/ACTION_FOR_STATE/etc. on window.
|
||||||
|
|
||||||
|
const { useState, useMemo, useRef, useEffect } = React;
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Iconography (tiny inline SVGs; no emoji)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
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>,
|
||||||
|
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>,
|
||||||
|
kebab: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}><circle cx="8" cy="3.2" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="8" cy="12.8" r="1.4"/></svg>,
|
||||||
|
sort: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4h10"/><path d="M4.5 8h7"/><path d="M6 12h4"/></svg>,
|
||||||
|
users: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="6" cy="6" r="2.4"/><path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13"/><circle cx="11.2" cy="5.4" r="1.8"/><path d="M10.4 9.8c1.7 0 3 1 3.6 2.6"/></svg>,
|
||||||
|
close: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...p}><path d="m4 4 8 8M12 4l-8 8"/></svg>,
|
||||||
|
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
|
||||||
|
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
|
||||||
|
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Cover art — stylized box-art placeholder
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function GameCover({ game, aspect = 'box', size = 'normal' }) {
|
||||||
|
const { c1, c2, accent } = game.cover;
|
||||||
|
// Pick title sizing — shrink for longer names; line-clamp:2 handles wrap
|
||||||
|
const title = game.title;
|
||||||
|
const len = title.length;
|
||||||
|
const fontPx = aspect === 'banner' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
|
||||||
|
: aspect === 'square' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
|
||||||
|
: (len > 26 ? 15 : len > 20 ? 17 : len > 14 ? 21 : 26);
|
||||||
|
|
||||||
|
// Stable but varied per-game accent shape (id hash → angle / size)
|
||||||
|
const h = [...game.id].reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||||
|
const angle = 110 + (h % 60); // 110-170
|
||||||
|
const blobX = 60 + (h % 30);
|
||||||
|
const blobY = 10 + ((h * 7) % 30);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cover" data-aspect={aspect}>
|
||||||
|
{/* base gradient */}
|
||||||
|
<div className="cover-base" style={{
|
||||||
|
background: `linear-gradient(${angle}deg, ${c1} 0%, ${c2} 100%)`,
|
||||||
|
}}/>
|
||||||
|
{/* radial accent blob */}
|
||||||
|
<div className="cover-blob" style={{
|
||||||
|
background: `radial-gradient(ellipse at ${blobX}% ${blobY}%, ${accent}38, transparent 55%)`,
|
||||||
|
}}/>
|
||||||
|
{/* scanline / grain */}
|
||||||
|
<div className="cover-grain"/>
|
||||||
|
{/* faint geometric mark */}
|
||||||
|
<svg className="cover-mark" viewBox="0 0 100 100" preserveAspectRatio="xMaxYMax slice" aria-hidden="true">
|
||||||
|
<path d={`M ${100 - (h%30)} ${100 - (h%20)} L 100 ${60 + (h%25)} L 100 100 Z`}
|
||||||
|
fill={accent} fillOpacity="0.12"/>
|
||||||
|
<circle cx={(h*3)%100} cy={(h*5)%100} r="0.6" fill={accent} fillOpacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
{/* title */}
|
||||||
|
<div className="cover-titlewrap">
|
||||||
|
<div className="cover-title" style={{ fontSize: fontPx, textShadow: `0 4px 16px ${c2}aa, 0 1px 0 rgba(0,0,0,.3)` }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* bottom darkening */}
|
||||||
|
<div className="cover-vignette"/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// State chip (corner of cover)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function StateChip({ state }) {
|
||||||
|
const meta = STATE_META[state];
|
||||||
|
if (!meta || !meta.label) return null;
|
||||||
|
return (
|
||||||
|
<div className="state-chip" data-state={state}>
|
||||||
|
<span className="state-dot" style={{ background: meta.dot }}/>
|
||||||
|
{meta.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Action button — Play / Install / Download
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function ActionButton({ state, accent, size = 'md', onClick, full = false }) {
|
||||||
|
const action = ACTION_FOR_STATE[state];
|
||||||
|
const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`;
|
||||||
|
const icon = action.kind === 'play' ? <Icon.play/>
|
||||||
|
: action.kind === 'install' ? <Icon.install/>
|
||||||
|
: <Icon.download/>;
|
||||||
|
return (
|
||||||
|
<button className={cls} onClick={(e) => { e.stopPropagation(); onClick && onClick(); }}
|
||||||
|
style={action.kind === 'install' ? { background: accent } : undefined}>
|
||||||
|
{icon}<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Game card
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function GameCard({ game, accent, aspect, onOpen }) {
|
||||||
|
return (
|
||||||
|
<article className="card" onClick={() => onOpen && onOpen(game)} tabIndex={0}>
|
||||||
|
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||||
|
<GameCover game={game} aspect={aspect}/>
|
||||||
|
<StateChip state={game.state}/>
|
||||||
|
<div className="card-mp" title={`${game.players} players`}>
|
||||||
|
<Icon.users/><span>{game.players}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-title" title={game.title}>{game.title}</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
<span>{fmtSize(game.size)}</span>
|
||||||
|
<span className="card-dot">·</span>
|
||||||
|
<span>{game.tags[0]}</span>
|
||||||
|
</div>
|
||||||
|
<ActionButton state={game.state} accent={accent} full/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Filter controls
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
const FILTER_TABS = [
|
||||||
|
{ key: 'all', label: 'All Games' },
|
||||||
|
{ key: 'local', label: 'Local' },
|
||||||
|
{ key: 'installed', label: 'Installed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SegmentedFilters({ value, onChange, counts, accent }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [thumb, setThumb] = useState({ left: 0, width: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const el = ref.current.querySelector(`[data-key="${value}"]`);
|
||||||
|
if (el) setThumb({ left: el.offsetLeft, width: el.offsetWidth });
|
||||||
|
}, [value]);
|
||||||
|
return (
|
||||||
|
<div className="seg" ref={ref}>
|
||||||
|
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width, background: accent }}/>
|
||||||
|
{FILTER_TABS.map(t => (
|
||||||
|
<button key={t.key} data-key={t.key} className={`seg-btn ${value === t.key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(t.key)}>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
<span className="seg-count">{counts[t.key]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnderlineFilters({ value, onChange, counts, accent }) {
|
||||||
|
return (
|
||||||
|
<div className="utabs">
|
||||||
|
{FILTER_TABS.map(t => (
|
||||||
|
<button key={t.key} className={`utab ${value === t.key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(t.key)}
|
||||||
|
style={value === t.key ? { '--accent': accent } : undefined}>
|
||||||
|
<span className="utab-label">{t.label}</span>
|
||||||
|
<span className="utab-count">{counts[t.key]}</span>
|
||||||
|
<span className="utab-underline" style={{ background: accent }}/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Search input
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function SearchField({ value, onChange, accent, wide = false }) {
|
||||||
|
return (
|
||||||
|
<div className={`search ${wide ? 'search-wide' : ''}`} style={{ '--accent': accent }}>
|
||||||
|
<Icon.search/>
|
||||||
|
<input type="text" placeholder="Search games" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}/>
|
||||||
|
<span className="search-kbd">/</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Sort menu (simple dropdown)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
const SORTS = [
|
||||||
|
{ key: 'az', label: 'Name (A–Z)' },
|
||||||
|
{ key: 'size', label: 'Size (largest)' },
|
||||||
|
{ key: 'recent', label: 'Recently Played' },
|
||||||
|
{ key: 'state', label: 'Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SortMenu({ value, onChange, accent }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('click', close);
|
||||||
|
return () => document.removeEventListener('click', close);
|
||||||
|
}, [open]);
|
||||||
|
const current = SORTS.find(s => s.key === value) || SORTS[0];
|
||||||
|
return (
|
||||||
|
<div className="sort" ref={ref}>
|
||||||
|
<button className="sort-btn" onClick={() => setOpen(o => !o)}>
|
||||||
|
<Icon.sort/>
|
||||||
|
<span>Sort: <strong>{current.label}</strong></span>
|
||||||
|
<Icon.chevron/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{SORTS.map(s => (
|
||||||
|
<button key={s.key} onClick={() => { onChange(s.key); setOpen(false); }}
|
||||||
|
className={s.key === value ? 'is-active' : ''}
|
||||||
|
style={s.key === value ? { color: accent } : undefined}>
|
||||||
|
<span className="sort-check">{s.key === value ? <Icon.check/> : null}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Storage meter
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function StorageMeter({ accent, compact = false }) {
|
||||||
|
const { installed, local, total } = STORAGE;
|
||||||
|
const pctI = (installed / total) * 100;
|
||||||
|
const pctL = (local / total) * 100;
|
||||||
|
return (
|
||||||
|
<div className={`storage ${compact ? 'storage-compact' : ''}`}>
|
||||||
|
<div className="storage-bar">
|
||||||
|
<div className="storage-i" style={{ width: `${pctI}%`, background: accent }}/>
|
||||||
|
<div className="storage-l" style={{ width: `${pctL}%`, background: `${accent}55` }}/>
|
||||||
|
</div>
|
||||||
|
<div className="storage-text">
|
||||||
|
<span><span className="storage-sq" style={{ background: accent }}/>{installed.toFixed(0)} GB installed</span>
|
||||||
|
<span><span className="storage-sq" style={{ background: `${accent}55` }}/>{local.toFixed(0)} GB local</span>
|
||||||
|
<span className="storage-free">{STORAGE.free.toFixed(0)} GB free</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Directory button (shows path)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function DirectoryButton({ path }) {
|
||||||
|
const short = path.length > 36 ? '…' + path.slice(-34) : path;
|
||||||
|
return (
|
||||||
|
<button className="dirbtn" title={path}>
|
||||||
|
<Icon.folder/>
|
||||||
|
<span className="dirbtn-label">Game directory</span>
|
||||||
|
<span className="dirbtn-path">{short}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Menu (kebab)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function KebabMenu({ items }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('click', close);
|
||||||
|
return () => document.removeEventListener('click', close);
|
||||||
|
}, [open]);
|
||||||
|
return (
|
||||||
|
<div className="kebab" ref={ref}>
|
||||||
|
<button className="kebab-btn" onClick={() => setOpen(o => !o)} aria-label="More"><Icon.kebab/></button>
|
||||||
|
{open && (
|
||||||
|
<div className="kebab-menu">
|
||||||
|
{items.map((it, i) => it === '-' ? <div key={i} className="kebab-sep"/> : (
|
||||||
|
<button key={i} onClick={() => { setOpen(false); it.onClick && it.onClick(); }}>{it.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Detail Modal
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
function GameDetailModal({ game, accent, onClose }) {
|
||||||
|
if (!game) return null;
|
||||||
|
const action = ACTION_FOR_STATE[game.state];
|
||||||
|
return (
|
||||||
|
<div className="modal-scrim" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
|
||||||
|
<div className="modal-hero">
|
||||||
|
<GameCover game={game} aspect="banner"/>
|
||||||
|
<div className="modal-hero-fade"/>
|
||||||
|
<div className="modal-hero-text">
|
||||||
|
<div className="modal-tags">
|
||||||
|
{game.tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
<h2 className="modal-title">{game.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="modal-state">
|
||||||
|
<StateChip state={game.state}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-meta">
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Size</div>
|
||||||
|
<div className="meta-value">{fmtSize(game.size)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Players</div>
|
||||||
|
<div className="meta-value"><Icon.users/> {game.players}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Version</div>
|
||||||
|
<div className="meta-value meta-mono">{game.version}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Status</div>
|
||||||
|
<div className="meta-value">{STATE_META[game.state].label || 'Not downloaded'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="modal-desc">{game.desc}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<ActionButton state={game.state} accent={accent} size="lg"/>
|
||||||
|
{game.state === 'installed' && (
|
||||||
|
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
|
||||||
|
)}
|
||||||
|
{game.state === 'local' && (
|
||||||
|
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Delete from disk</span></button>
|
||||||
|
)}
|
||||||
|
{game.state !== 'none' && <div className="modal-actions-spacer"/>}
|
||||||
|
<button className="ghost-btn">View files</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Settings Dialog — in-app version of the Tweaks panel
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
const SETTING_OPTIONS = {
|
||||||
|
accent: [
|
||||||
|
{ value: '#3b82f6', label: 'Blue' },
|
||||||
|
{ value: '#22d3ee', label: 'Cyan' },
|
||||||
|
{ value: '#a855f7', label: 'Violet' },
|
||||||
|
{ value: '#22c55e', label: 'Green' },
|
||||||
|
{ value: '#f59e0b', label: 'Amber' },
|
||||||
|
{ value: '#ef4444', label: 'Red' },
|
||||||
|
],
|
||||||
|
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' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function SettingsRow({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<div className="settings-row">
|
||||||
|
<div className="settings-row-info">
|
||||||
|
<div className="settings-row-label">{label}</div>
|
||||||
|
{hint && <div className="settings-row-hint">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="settings-row-control">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentedRadio({ value, options, onChange, accent }) {
|
||||||
|
return (
|
||||||
|
<div className="srad">
|
||||||
|
{options.map(o => (
|
||||||
|
<button key={o.value}
|
||||||
|
className={`srad-btn ${value === o.value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
style={value === o.value ? { background: accent, borderColor: accent } : undefined}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorSwatchPicker({ value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="swatch-row">
|
||||||
|
{options.map(o => (
|
||||||
|
<button key={o.value}
|
||||||
|
className={`swatch ${value === o.value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
style={{ color: o.value }}
|
||||||
|
title={o.label}
|
||||||
|
aria-label={o.label}>
|
||||||
|
<span className="swatch-dot" style={{ background: o.value }}/>
|
||||||
|
{value === o.value && <span className="swatch-check"><Icon.check/></span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsDialog({ settings, onChange, onClose }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-scrim" onClick={onClose}>
|
||||||
|
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="settings-head">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<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">Appearance</div>
|
||||||
|
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
|
||||||
|
<ColorSwatchPicker value={settings.accent}
|
||||||
|
options={SETTING_OPTIONS.accent}
|
||||||
|
onChange={(v) => onChange('accent', v)}/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow label="Background" hint="Backdrop behind the library">
|
||||||
|
<SegmentedRadio value={settings.bg}
|
||||||
|
options={SETTING_OPTIONS.bg}
|
||||||
|
onChange={(v) => onChange('bg', v)}
|
||||||
|
accent={settings.accent}/>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-section-title">Library</div>
|
||||||
|
<SettingsRow label="Grid density" hint="How tightly cards are packed">
|
||||||
|
<SegmentedRadio value={settings.density}
|
||||||
|
options={SETTING_OPTIONS.density}
|
||||||
|
onChange={(v) => onChange('density', v)}
|
||||||
|
accent={settings.accent}/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow label="Cover aspect" hint="Shape of the cover art on each card">
|
||||||
|
<SegmentedRadio value={settings.aspect}
|
||||||
|
options={SETTING_OPTIONS.aspect}
|
||||||
|
onChange={(v) => onChange('aspect', v)}
|
||||||
|
accent={settings.accent}/>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-foot">
|
||||||
|
<button className="ghost-btn settings-done" onClick={onClose}
|
||||||
|
style={{ background: settings.accent, borderColor: settings.accent, color: 'white' }}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
Icon, GameCover, StateChip, ActionButton, GameCard,
|
||||||
|
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
|
||||||
|
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
|
||||||
|
SettingsDialog,
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// data.jsx — game catalog for the SoftLAN launcher mock
|
||||||
|
// Each game has: id, title, size (GB), version (date), description, state, players (min-max), tags, cover (color pair + optional accent shape)
|
||||||
|
// state: 'installed' | 'local' | 'none' (local = downloaded but not installed yet)
|
||||||
|
|
||||||
|
const GAMES = [
|
||||||
|
{
|
||||||
|
id: '8bitarmies', title: '8-Bit Armies', size: 1.9, version: '2016.10.24',
|
||||||
|
desc: "A fast-paced retro-styled RTS with bright voxel armies, three factions, and zero patience for slow players. Tank-rush, build queues, and a campaign that doesn't waste your time.",
|
||||||
|
state: 'installed', players: '1–8', tags: ['RTS', 'Multiplayer', 'LAN'],
|
||||||
|
cover: { c1: '#f59e0b', c2: '#b91c1c', accent: '#fde047', mood: 'arcade' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aoe2hd', title: 'Age of Empires II (HD)', size: 8.6, version: '2018.01.31',
|
||||||
|
desc: "The HD remaster of the strategy classic. Lead one of thirteen civilizations from the dark ages through the imperial age, and finally settle who actually deserved that wonder.",
|
||||||
|
state: 'local', players: '1–8', tags: ['RTS', 'Historical', 'LAN'],
|
||||||
|
cover: { c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24', mood: 'gothic' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01',
|
||||||
|
desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.",
|
||||||
|
state: 'none', players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'],
|
||||||
|
cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amongus', title: 'Among Us', size: 0.3, version: '2021.11.05',
|
||||||
|
desc: "Crewmates fix the ship. Impostors sabotage it and vent through walls. Friendships are tested. The orange one is always sus.",
|
||||||
|
state: 'installed', players: '4–15', tags: ['Social Deduction', 'Casual'],
|
||||||
|
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30',
|
||||||
|
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',
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blazerush', title: 'BlazeRush', size: 1.3, version: '2021.12.27',
|
||||||
|
desc: "Top-down arcade racing with no fuel, no health bar, and absolutely no brakes. Ram, boost, win.",
|
||||||
|
state: 'none', players: '1–8', tags: ['Racing', 'Arcade'],
|
||||||
|
cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ra3', title: 'C&C: Red Alert 3', size: 8.2, version: '2018.04.12',
|
||||||
|
desc: "Cold War alt-history RTS. Soviets, Allies, and the Empire of the Rising Sun — every cutscene live-action and absolutely deranged.",
|
||||||
|
state: 'installed', players: '1–6', tags: ['RTS', 'Co-op'],
|
||||||
|
cover: { c1: '#991b1b', c2: '#450a0a', accent: '#fde047', mood: 'propaganda' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cncgen', title: 'C&C Generals: Zero Hour', size: 2.1, version: '2017.11.15',
|
||||||
|
desc: "Modern-warfare RTS expansion with generals challenges. USA, China, GLA — pick your asymmetry and rush something.",
|
||||||
|
state: 'local', players: '1–8', tags: ['RTS', 'Modern'],
|
||||||
|
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'portal2', title: 'Portal 2', size: 11.0, version: '2014.01.01',
|
||||||
|
desc: "Puzzle-shooter sequel with a full co-op campaign — two players, two portals each, infinite ways to get GLaDOS to insult you.",
|
||||||
|
state: 'local', players: '1–2', tags: ['Puzzle', 'Co-op'],
|
||||||
|
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
|
||||||
|
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
|
||||||
|
state: 'none', players: '2–16', tags: ['FPS', 'Arena', 'LAN'],
|
||||||
|
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starcraft', title: 'StarCraft: Brood War', size: 1.2, version: '2018.04.16',
|
||||||
|
desc: "Sci-fi RTS — Terran, Zerg, Protoss. Still played at the highest level decades later for a reason.",
|
||||||
|
state: 'installed', players: '1–8', tags: ['RTS', 'Sci-Fi'],
|
||||||
|
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18',
|
||||||
|
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',
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warcraft3', title: 'Warcraft III: TFT', size: 1.3, version: '2018.03.25',
|
||||||
|
desc: "Hero-driven RTS whose custom-game scene birthed Dota, tower defense, and at least three other genres that ate the industry.",
|
||||||
|
state: 'installed', players: '1–12', tags: ['RTS', 'Fantasy'],
|
||||||
|
cover: { c1: '#a16207', c2: '#422006', accent: '#fbbf24', mood: 'fantasy' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const fmtSize = (gb) => gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb.toFixed(1)} GB`;
|
||||||
|
const STATE_META = {
|
||||||
|
installed: { label: 'Installed', dot: '#22c55e' },
|
||||||
|
local: { label: 'Local', dot: '#f59e0b' },
|
||||||
|
none: { label: '', dot: 'transparent' },
|
||||||
|
};
|
||||||
|
const ACTION_FOR_STATE = {
|
||||||
|
installed: { label: 'Play', kind: 'play' },
|
||||||
|
local: { label: 'Install', kind: 'install' },
|
||||||
|
none: { label: 'Download', kind: 'download' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const countByFilter = (games) => ({
|
||||||
|
all: games.length,
|
||||||
|
local: games.filter(g => g.state === 'installed' || g.state === 'local').length,
|
||||||
|
installed: games.filter(g => g.state === 'installed').length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterGames = (games, key) => {
|
||||||
|
if (key === 'all') return games;
|
||||||
|
if (key === 'local') return games.filter(g => g.state === 'installed' || g.state === 'local');
|
||||||
|
if (key === 'installed') return games.filter(g => g.state === 'installed');
|
||||||
|
return games;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage figures (mock)
|
||||||
|
const STORAGE = {
|
||||||
|
installed: 78.4, // GB
|
||||||
|
local: 41.2,
|
||||||
|
free: 384.1,
|
||||||
|
total: 512,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.GAMES = GAMES;
|
||||||
|
window.STATE_META = STATE_META;
|
||||||
|
window.ACTION_FOR_STATE = ACTION_FOR_STATE;
|
||||||
|
window.countByFilter = countByFilter;
|
||||||
|
window.filterGames = filterGames;
|
||||||
|
window.fmtSize = fmtSize;
|
||||||
|
window.STORAGE = STORAGE;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// launcher.jsx — composes top bar + grid into a complete launcher screen
|
||||||
|
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
|
||||||
|
|
||||||
|
const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025';
|
||||||
|
|
||||||
|
function applyFilterAndSort(games, filter, sort, query) {
|
||||||
|
let g = filterGames(games, filter);
|
||||||
|
if (query.trim()) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
g = g.filter(x => x.title.toLowerCase().includes(q) || x.tags.some(t => t.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
if (sort === 'az') g = [...g].sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
else if (sort === 'size') g = [...g].sort((a, b) => b.size - a.size);
|
||||||
|
else if (sort === 'state') {
|
||||||
|
const order = { installed: 0, local: 1, none: 2 };
|
||||||
|
g = [...g].sort((a, b) => order[a.state] - order[b.state] || a.title.localeCompare(b.title));
|
||||||
|
} else if (sort === 'recent') {
|
||||||
|
const order = { installed: 0, local: 1, none: 2 };
|
||||||
|
g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Launcher({
|
||||||
|
variant,
|
||||||
|
tweaks, setTweak,
|
||||||
|
initialFilter = 'all', initialSort = 'recent', initialQuery = '',
|
||||||
|
initialOpenGame = null,
|
||||||
|
initialSettingsOpen = false,
|
||||||
|
}) {
|
||||||
|
const { density, aspect, accent, bg } = tweaks;
|
||||||
|
const [filter, setFilter] = useState(initialFilter);
|
||||||
|
const [sort, setSort] = useState(initialSort);
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [openGame, setOpenGame] = useState(initialOpenGame);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen);
|
||||||
|
|
||||||
|
const counts = useMemo(() => countByFilter(GAMES), []);
|
||||||
|
const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]);
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ label: 'Settings', onClick: () => setSettingsOpen(true) },
|
||||||
|
{ label: 'Refresh library', onClick: () => {} },
|
||||||
|
'-',
|
||||||
|
{ label: 'Unpack logs', onClick: () => {} },
|
||||||
|
{ label: 'About SoftLAN', onClick: () => {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`launcher launcher-${variant} bg-${bg} density-${density}`}
|
||||||
|
style={{ '--accent': accent }}>
|
||||||
|
{variant === 'single' ? (
|
||||||
|
<header className="topbar topbar-single">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||||
|
<div className="brand-name">SoftLAN</div>
|
||||||
|
</div>
|
||||||
|
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
||||||
|
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
||||||
|
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
||||||
|
<DirectoryButton path={DIR_PATH}/>
|
||||||
|
<KebabMenu items={menuItems}/>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<header className="topbar topbar-two">
|
||||||
|
<div className="topbar-row topbar-row1">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||||
|
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
|
||||||
|
</div>
|
||||||
|
<DirectoryButton path={DIR_PATH}/>
|
||||||
|
<div className="topbar-row1-right">
|
||||||
|
<StorageMeter accent={accent}/>
|
||||||
|
<KebabMenu items={menuItems}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-row topbar-row2">
|
||||||
|
<UnderlineFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
||||||
|
<div className="topbar-row2-right">
|
||||||
|
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
||||||
|
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="grid-wrap">
|
||||||
|
{variant === 'single' && (
|
||||||
|
<div className="results-bar">
|
||||||
|
<div className="results-count">
|
||||||
|
Showing <strong>{list.length}</strong> of {counts.all} games
|
||||||
|
</div>
|
||||||
|
<StorageMeter accent={accent} compact/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid">
|
||||||
|
{list.map(g => (
|
||||||
|
<GameCard key={g.id} game={g} accent={accent} aspect={aspect}
|
||||||
|
onOpen={(game) => setOpenGame(game)}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{openGame && <GameDetailModal game={openGame} accent={accent} onClose={() => setOpenGame(null)}/>}
|
||||||
|
{settingsOpen && setTweak && (
|
||||||
|
<SettingsDialog settings={tweaks} onChange={setTweak} onClose={() => setSettingsOpen(false)}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Launcher = Launcher;
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
/* SoftLAN Launcher — styles
|
||||||
|
Steam-like dark UI, blue accent (configurable). System sans for UI,
|
||||||
|
Bebas Neue for cover-art display type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--bg-0: #0a0e13;
|
||||||
|
--bg-1: #0f151c;
|
||||||
|
--bg-2: #131b25;
|
||||||
|
--bg-3: #1a2330;
|
||||||
|
--bg-4: #232f3e;
|
||||||
|
--bd-1: rgba(255,255,255,0.06);
|
||||||
|
--bd-2: rgba(255,255,255,0.10);
|
||||||
|
--bd-3: rgba(255,255,255,0.16);
|
||||||
|
--t-1: #e6edf3;
|
||||||
|
--t-2: #9aa6b4;
|
||||||
|
--t-3: #6b7785;
|
||||||
|
--t-4: #4a5663;
|
||||||
|
--ok: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", Inter, system-ui, sans-serif;
|
||||||
|
--font-display: "Bebas Neue", "Oswald", Impact, "Arial Narrow Bold", sans-serif;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* ─── Launcher root ─── */
|
||||||
|
.launcher {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--t-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background variants */
|
||||||
|
.bg-flat { background: var(--bg-0); }
|
||||||
|
.bg-gradient {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent) 0%, transparent 60%),
|
||||||
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
|
}
|
||||||
|
.bg-animated {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 40% at 20% 0%, color-mix(in srgb, var(--accent) 24%, transparent) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 55% 40% at 85% 8%, color-mix(in srgb, var(--accent) 16%, transparent) 0%, transparent 55%),
|
||||||
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
animation: bgshift 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes bgshift {
|
||||||
|
0% { background-position: 0% 0%, 0% 0%, 0% 0%; }
|
||||||
|
100% { background-position: 10% 4%, -6% 2%, 0% 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Top bar — shared ─── */
|
||||||
|
.topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(10,14,19,0.65);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant 1: single row */
|
||||||
|
.topbar-single {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant 2: two row */
|
||||||
|
.topbar-two .topbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
}
|
||||||
|
.topbar-two .topbar-row1 {
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
.topbar-two .topbar-row1-right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
|
||||||
|
.topbar-two .topbar-row2 { padding-top: 4px; padding-bottom: 4px; }
|
||||||
|
.topbar-two .topbar-row2-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* ─── Brand ─── */
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black), inset 0 1px 0 rgba(255,255,255,0.22);
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.brand-name-soft { color: var(--t-3); font-weight: 500; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ─── Segmented filters (variant 1) ─── */
|
||||||
|
.seg {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px; bottom: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: left .22s cubic-bezier(.4,1.2,.5,1), width .22s cubic-bezier(.4,1.2,.5,1), background .15s;
|
||||||
|
box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, black);
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.seg-btn:hover { color: var(--t-1); }
|
||||||
|
.seg-btn.is-active { color: white; }
|
||||||
|
.seg-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.seg-btn.is-active .seg-count { background: rgba(0,0,0,0.25); color: white; }
|
||||||
|
|
||||||
|
/* ─── Underline filters (variant 2) ─── */
|
||||||
|
.utabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.utab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.utab:hover { color: var(--t-1); }
|
||||||
|
.utab.is-active { color: var(--t-1); }
|
||||||
|
.utab-count {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--t-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.utab.is-active .utab-count { color: var(--t-1); background: rgba(255,255,255,0.10); }
|
||||||
|
.utab-underline {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px; right: 12px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.4);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: opacity .2s, transform .25s cubic-bezier(.4,1.2,.5,1);
|
||||||
|
}
|
||||||
|
.utab.is-active .utab-underline { opacity: 1; transform: scaleX(1); }
|
||||||
|
|
||||||
|
/* ─── Search ─── */
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 220px;
|
||||||
|
color: var(--t-3);
|
||||||
|
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.search-wide { min-width: 320px; flex: 0 1 380px; }
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
|
||||||
|
background: var(--bg-1);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
.search:focus-within { color: var(--t-1); }
|
||||||
|
.search input {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: transparent; border: 0; outline: 0;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.search input::placeholder { color: var(--t-3); }
|
||||||
|
.search-kbd {
|
||||||
|
display: inline-grid; place-items: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.search:focus-within .search-kbd { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* ─── Sort menu ─── */
|
||||||
|
.sort { position: relative; flex-shrink: 0; }
|
||||||
|
.sort-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
height: 36px; padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit; font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.sort-btn:hover { color: var(--t-1); border-color: var(--bd-2); }
|
||||||
|
.sort-btn strong { color: var(--t-1); font-weight: 600; }
|
||||||
|
.sort-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.sort-menu button {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit; font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-menu button:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.sort-check { width: 14px; display: inline-grid; place-items: center; color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Directory button ─── */
|
||||||
|
.dirbtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
height: 36px; padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit; font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 360px;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
|
||||||
|
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
|
||||||
|
.dirbtn-path {
|
||||||
|
color: var(--t-3);
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Kebab menu ─── */
|
||||||
|
.kebab { position: relative; }
|
||||||
|
.kebab-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.kebab-btn:hover { color: var(--t-1); border-color: var(--bd-2); }
|
||||||
|
.kebab-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.kebab-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit; font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kebab-menu button:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.kebab-sep { height: 1px; background: var(--bd-1); margin: 4px 0; }
|
||||||
|
|
||||||
|
/* ─── Storage meter ─── */
|
||||||
|
.storage {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.storage-compact { min-width: 200px; }
|
||||||
|
.storage-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-i { position: absolute; top: 0; left: 0; bottom: 0; }
|
||||||
|
.storage-l {
|
||||||
|
position: absolute; top: 0; bottom: 0;
|
||||||
|
left: calc((var(--installed-pct, 15.3)) * 1%);
|
||||||
|
}
|
||||||
|
.storage-compact .storage-bar { height: 4px; }
|
||||||
|
.storage-text {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.storage-text > span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.storage-sq {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.storage-free { margin-left: auto; color: var(--t-2); }
|
||||||
|
|
||||||
|
/* ─── Grid wrapper / results bar ─── */
|
||||||
|
.grid-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px 24px 32px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bd-3) transparent;
|
||||||
|
}
|
||||||
|
.grid-wrap::-webkit-scrollbar { width: 10px; }
|
||||||
|
.grid-wrap::-webkit-scrollbar-thumb { background: var(--bd-3); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
|
|
||||||
|
.results-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 4px 4px 16px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.results-count { color: var(--t-2); font-size: 12.5px; }
|
||||||
|
.results-count strong { color: var(--t-1); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ─── Grid ─── */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--card-gap, 16px);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 188px), 1fr));
|
||||||
|
}
|
||||||
|
.density-compact { --card-min: 148px; --card-gap: 12px; }
|
||||||
|
.density-normal { --card-min: 188px; --card-gap: 16px; }
|
||||||
|
.density-large { --card-min: 244px; --card-gap: 20px; }
|
||||||
|
|
||||||
|
/* ─── Card ─── */
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform .18s cubic-bezier(.4,1.2,.5,1), border-color .18s, box-shadow .18s;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.card:hover, .card:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||||
|
box-shadow:
|
||||||
|
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
.card:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black),
|
||||||
|
0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Cover ─── */
|
||||||
|
.card-cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
.card-cover-wrap[data-aspect="box"] { aspect-ratio: 2 / 3; }
|
||||||
|
.card-cover-wrap[data-aspect="square"] { aspect-ratio: 1 / 1; }
|
||||||
|
.card-cover-wrap[data-aspect="banner"] { aspect-ratio: 16 / 9; }
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform .35s cubic-bezier(.4,1.2,.5,1);
|
||||||
|
}
|
||||||
|
.card:hover .cover { transform: scale(1.03); }
|
||||||
|
.cover-base, .cover-blob, .cover-grain, .cover-vignette, .cover-mark { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.cover-grain {
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 3px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(0,0,0,0.04) 0 1px, transparent 1px 3px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.cover-vignette {
|
||||||
|
background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%);
|
||||||
|
}
|
||||||
|
.cover-mark { width: 100%; height: 100%; }
|
||||||
|
.cover-titlewrap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 14px 14px 14px;
|
||||||
|
}
|
||||||
|
.cover-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.018em;
|
||||||
|
line-height: 1.02;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: white;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
.cover-sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* banner mode: title centered + smaller padding */
|
||||||
|
.card-cover-wrap[data-aspect="banner"] .cover-titlewrap {
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── State chip ─── */
|
||||||
|
.state-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px; right: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8,12,16,0.78);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.state-dot { width: 6px; height: 6px; border-radius: 999px; box-shadow: 0 0 8px currentColor; }
|
||||||
|
.state-chip[data-state="installed"] .state-dot { box-shadow: 0 0 8px var(--ok); }
|
||||||
|
.state-chip[data-state="local"] .state-dot { box-shadow: 0 0 8px var(--warn); }
|
||||||
|
|
||||||
|
/* Multiplayer badge */
|
||||||
|
.card-mp {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px; left: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8,12,16,0.65);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Card body ─── */
|
||||||
|
.card-body {
|
||||||
|
padding: 11px 12px 12px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--t-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.card-meta .card-dot { opacity: 0.5; }
|
||||||
|
|
||||||
|
.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; }
|
||||||
|
.density-compact .card-title { font-size: 12.5px; }
|
||||||
|
.density-compact .card-meta { font-size: 11px; }
|
||||||
|
.density-large .card-body { padding: 14px 14px 14px; gap: 10px; }
|
||||||
|
.density-large .card-title { font-size: 15px; }
|
||||||
|
|
||||||
|
/* ─── Action buttons ─── */
|
||||||
|
.act-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .12s, filter .12s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.act-btn:hover { filter: brightness(1.12); }
|
||||||
|
.act-btn:active { transform: scale(0.98); }
|
||||||
|
.act-full { width: 100%; }
|
||||||
|
.act-lg { height: 44px; padding: 0 22px; font-size: 14px; gap: 8px; }
|
||||||
|
.act-lg svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
.act-play {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(180deg, #2bd07f 0%, #1aa460 100%);
|
||||||
|
box-shadow: 0 6px 16px -8px #1aa460, inset 0 1px 0 rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.act-install {
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent) 80%, black), inset 0 1px 0 rgba(255,255,255,0.22);
|
||||||
|
}
|
||||||
|
.act-download {
|
||||||
|
color: var(--t-1);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
|
||||||
|
|
||||||
|
/* Ghost / secondary */
|
||||||
|
.ghost-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
height: 44px; padding: 0 18px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit; font-size: 13.5px; font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.ghost-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
|
||||||
|
.ghost-danger { color: #f87171; }
|
||||||
|
.ghost-danger:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.40); color: #fca5a5; }
|
||||||
|
|
||||||
|
/* ─── Modal ─── */
|
||||||
|
.modal-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(4,7,11,0.7);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
animation: fadein .18s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
.modal {
|
||||||
|
width: min(880px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
animation: modalin .25s cubic-bezier(.3,1.3,.4,1);
|
||||||
|
}
|
||||||
|
@keyframes modalin { from { transform: scale(.96) translateY(8px); opacity: 0 } to { transform: scale(1) translateY(0); opacity: 1 } }
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px; right: 14px;
|
||||||
|
z-index: 5;
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: rgba(8,12,16,0.7);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--t-1);
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: rgba(255,255,255,0.10); border-color: var(--bd-3); }
|
||||||
|
.modal-hero {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-hero .cover { transform: none !important; }
|
||||||
|
.modal-hero-fade {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(180deg, transparent 40%, var(--bg-2) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.modal-hero-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 28px; right: 28px; bottom: 22px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.modal-hero-text .modal-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: white;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
text-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.modal-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.modal-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(8,12,16,0.6);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--bd-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.modal-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 24px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.modal-state .state-chip {
|
||||||
|
position: static;
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner cover treatment inside modal: hide the cover's own title (we show our own h2) */
|
||||||
|
.modal-hero .cover-titlewrap { opacity: 0.14; }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 22px 28px 26px;
|
||||||
|
display: flex; flex-direction: column; gap: 18px;
|
||||||
|
}
|
||||||
|
.modal-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.meta-cell {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.meta-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
|
||||||
|
|
||||||
|
.modal-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--t-2);
|
||||||
|
text-wrap: pretty;
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.modal-actions-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ─── Settings dialog ─── */
|
||||||
|
.settings-modal {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.settings-head {
|
||||||
|
position: relative;
|
||||||
|
padding: 22px 28px 18px;
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
.settings-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.settings-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.settings-close:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.settings-body {
|
||||||
|
padding: 22px 28px 26px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 26px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.settings-row-info { min-width: 0; flex: 1; }
|
||||||
|
.settings-row-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.settings-row-hint {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.settings-row-control { flex-shrink: 0; }
|
||||||
|
.settings-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 14px 22px 18px;
|
||||||
|
border-top: 1px solid var(--bd-1);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.settings-done {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 22px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.settings-done:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Settings: color swatches ─── */
|
||||||
|
.swatch-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.swatch {
|
||||||
|
position: relative;
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.swatch-dot {
|
||||||
|
display: block;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||||
|
transition: transform .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.swatch:hover .swatch-dot { transform: scale(1.06); }
|
||||||
|
.swatch.is-active .swatch-dot {
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px currentColor;
|
||||||
|
}
|
||||||
|
.swatch-check {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Settings: segmented radio ─── */
|
||||||
|
.srad {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.srad-btn {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--t-2);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.srad-btn:hover { color: var(--t-1); }
|
||||||
|
.srad-btn.is-active {
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user