Compare commits

...

4 Commits

Author SHA1 Message Date
ddidderr 640214ec38 feat(tauri): implement Steam-style launcher redesign per design handoff
Replace the previous monolithic 900-line `App.tsx` launcher UI with the
Steam-inspired dark redesign specified in `design/README.md` (handoff
committed in the previous commit). The new UI is split across small,
single-responsibility React modules instead of one file.

What changes from the user's perspective
----------------------------------------

- Dark, gradient-tinted background with sticky 64px top bar (glass blur
  + saturate). Single-row chrome (handoff variant A).
- Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an
  animated thumb that slides between options.
- Search field with magnifying-glass icon and a `/` keyboard shortcut to
  focus it from anywhere outside an input.
- Sort menu (Name A–Z / Size / Status) as a dropdown.
- Game directory button shows the current path with leading-ellipsis
  truncation; clicking it opens the native folder picker.
- Kebab menu hosts Settings, Refresh library, and Unpack logs (existing
  companion window). The standalone Unpack-Logs button is removed from
  the chrome.
- Game grid uses CSS `auto-fill` minmax with three density presets
  (compact / normal / large) and three cover aspects (box / square /
  banner), persisted via the Settings dialog.
- Game cards render with the real thumbnail when the backend has one
  (via `get_game_thumbnail`) and fall back to a procedurally-generated
  gradient + accent-blob placeholder with a Bebas Neue title burned in.
  Each card carries a color-coded state chip (Installed = green,
  Downloaded = amber, busy = pulsing accent), a peers chip when at
  least one peer holds the game, the title, size · genre meta line, a
  status line (errors in red), and a single color-coded primary action
  button: Play (green gradient), Update / Install (accent), Download
  (neutral), animated "busy" spinner during in-flight operations, or a
  disabled "Unavailable" state when no peer has the game.
- Clicking anywhere on a card except the action button opens a detail
  modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived
  from genre/publisher/release_year, large title, 4-cell meta grid
  (size, players from `max_players`, version from `local_version` or
  `eti_game_version` formatted YYYY.MM.DD, status), description, and an
  action row with the primary action plus an Uninstall ghost-danger
  button when the game is installed. Esc, scrim click, and the close
  button all dismiss the modal.
- Settings dialog (opened from the kebab menu) lets the user change the
  accent color (six swatches), background style (flat / gradient /
  animated), grid density, and cover aspect. Changes apply live and
  persist immediately to the Tauri store under `launcher-settings.json`
  (key `ui-settings`); the existing `game-directory` key in the same
  file is unchanged.
- Empty state when no directory is chosen offers a centered prompt with
  a single CTA. Empty state when filters/search match nothing shows a
  distinct "Nothing matches" message.

Why this approach
-----------------

The handoff selected variant A (single-row chrome) explicitly, so only
that variant is implemented; variant B underlined tabs and the
storage-meter widget are intentionally omitted (no free-space data
available from the backend yet).

Real cover art from `get_game_thumbnail` is preferred over the
placeholder generator. When a thumbnail is present, the Bebas Neue
title overlay is suppressed because shipped cover art already has its
own title. When the thumbnail is absent, the placeholder gradient (with
per-id stable hue/blob/angle) plus the burned-in title takes over —
this is the same procedural look as the design reference.

Architecture / file layout
--------------------------

The previous single-file design is decomposed top-down:

```
src/
  main.tsx                    entry; loads tokens + launcher CSS
  App.tsx                     thin router (main vs. unpack-logs view)
  styles/
    tokens.css                CSS custom props + body reset
    launcher.css              port of the design reference styles.css
                              (single-row chrome only)
  windows/
    MainWindow.tsx            composition root: top bar + grid + modals
  lib/
    types.ts                  Game, InstallStatus, GameAvailability,
                              ActiveOperationKind, GameFilter / GameSort,
                              DerivedState
    gameState.ts              derive() + isUnavailable + needsUpdate +
                              primaryActionFor + actionLabel +
                              mergeGameUpdate (event reconciliation) +
                              countByFilter + applyFilterAndSort
    format.ts                 formatBytes, formatEtiVersion (YYYYMMDD),
                              truncatePath, formatPlayers
    cover.ts                  coverColorsFor(id) — stable palette pick +
                              gradient angle + blob position from id
                              hash; titleFontSize
    store.ts                  file + key constants for plugin-store
  hooks/
    useSettings.ts            UISettings + accent/bg/density/aspect/
                              sort/filter, persisted via plugin-store
    useGameDirectory.ts       loads + persists the chosen directory and
                              pushes it to update_game_directory
    useGames.ts               owns the games list; listens to every
                              backend event (games-list-updated,
                              game-download-begin/finished/failed/
                              peers-gone, game-no-peers, game-install-
                              begin/finished/failed, game-uninstall-
                              begin/finished/failed, peer-count-updated);
                              exposes markChecking with a 5s fallback to
                              clear "Checking peers…" when nothing comes
                              back from the backend
    useGameActions.ts         play / install / update / uninstall
                              wrappers around the corresponding invoke
                              commands
    useThumbnails.ts          lazy per-id cache for get_game_thumbnail
  components/
    Icon.tsx                  inline SVG icon set (currentColor)
    Brand.tsx                 brand mark + name + peer-count chip
    Modal.tsx                 generic scrim + panel + Esc handler
    StateChip.tsx             corner pill with state-coded dot
    ActionButton.tsx          color-coded primary action; disabled when
                              unavailable; spinner when busy
    SegmentedRadio.tsx        generic 3-way segmented control
    ColorSwatchPicker.tsx     6-swatch picker with check overlay
    topbar/
      TopBar.tsx              chrome composition
      SegmentedFilters.tsx    All / Local / Installed with sliding thumb
      SearchField.tsx         input + `/` shortcut
      SortMenu.tsx            dropdown sort selector
      DirectoryButton.tsx     folder picker trigger
      KebabMenu.tsx           generic dropdown menu
    grid/
      ResultsBar.tsx          "Showing N of M games"
      GameGrid.tsx            CSS-grid wrapper
      GameCard.tsx            full card composition
      GameCover.tsx           thumbnail OR placeholder cover art
    modals/
      GameDetailModal.tsx     hero + meta grid + actions
      SettingsDialog.tsx      appearance + library preferences
    empty/
      NoDirectoryState.tsx    onboarding CTA
      EmptyResultsState.tsx   "scanning" / "nothing matches"
```

`UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs
companion window is rendered as before via the existing `?view=unpack-
logs` route in `App.tsx`.

The previous `App.css` is removed entirely (its styles are superseded
by `styles/launcher.css`).

Bebas Neue is loaded via Google Fonts in `index.html` (preconnect +
swap), used for the brand mark and the placeholder cover-art titles.

Tradeoffs and intentional omissions
-----------------------------------

- Storage meter: omitted. The handoff specifies installed/local/free
  bytes, but no Tauri command currently provides free-space data.
- Variant B (two-row chrome with underline tabs): omitted; the handoff
  picked variant A.
- "View files" action in the detail modal: omitted. The backend doesn't
  expose per-game install paths and `shell.open` of the user-chosen
  root directory would be misleading.
- "Delete from disk" ghost-danger action for `local` games: omitted.
  No backend command currently distinguishes "delete downloaded
  archive" from `uninstall_game`. Only installed games get an Uninstall
  button.
- "Recently Played" sort: omitted (no play-time tracking yet). The sort
  menu offers Name / Size / Status instead.
- Keyboard arrow grid navigation: not yet implemented (out of scope per
  the handoff).
- Per-game progress bar during downloads/installs: not implemented; the
  action button shows a spinner + "Downloading…" / "Installing…" label
  instead, matching the existing event-driven status text.

Persistence
-----------

UI preferences (accent, bg, density, aspect, sort, filter) live in
`launcher-settings.json` under a new `ui-settings` key. The existing
`game-directory` key in the same file is preserved untouched, so users
keep their previously selected directory.

Test plan
---------

Frontend build verified locally:

  cd crates/lanspread-tauri-deno-ts && deno task build
  → `tsc && vite build` completes with no diagnostics; bundle ~228 kB.

Manual verification (recommended once the app boots end-to-end):

- [ ] Launch with no directory set: only the "Pick a game directory"
      empty state is visible; clicking the button opens the native
      folder picker.
- [ ] Pick a directory: top bar appears, grid populates as games arrive.
- [ ] Click the All / Local / Installed pills: the thumb slides; the
      count chips reflect the right subset.
- [ ] Press `/`: focus moves to the search input; type a substring and
      confirm the grid filters live.
- [ ] Open the Sort menu, switch between sorts; the grid reorders.
- [ ] Open the Settings dialog from the kebab: change accent → the
      thumb, brand mark, search-focus ring, and Install button all
      switch color live. Change density → grid card size changes.
      Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and
      reopen: choices are remembered.
- [ ] Click anywhere on a card except the action button → detail modal
      opens with the right metadata; Esc / scrim click / close button
      all dismiss it.
- [ ] Click the action button on an `installed` card → game launches.
- [ ] Click the action button on a `local` card → install starts;
      button shows the spinner + "Installing…".
- [ ] Click on a `none` card with peer_count > 0 → download starts; the
      lifecycle events update the button label correctly.
- [ ] Card for a game with peer_count == 0 and not downloaded → button
      reads "Unavailable" and is disabled.
- [ ] Trigger a `game-download-failed` from the backend: the error
      status line appears under the card title in red.
- [ ] Open Unpack Logs from the kebab: the companion window opens
      exactly as before.

Trailer
-------
Refs: design/README.md (canonical handoff), design/design_reference/
2026-05-19 20:12:57 +02:00
ddidderr 27c71978d2 docs(design): add SoftLAN launcher redesign handoff and references
Add the `design/` directory containing the design handoff document and
HTML/React reference prototypes for the planned Steam-inspired redesign
of the launcher UI.

Contents:

- `design/README.md` — handoff spec. Defines screens (main library,
  game detail overlay, in-app Settings dialog), the game card anatomy,
  interaction behavior, transitions, state shape, design tokens
  (colors, typography, spacing, shadows) and out-of-scope items.
  Selects layout variant A (single-row top bar) as the primary
  direction. High-fidelity: colors / typography / spacing / animations
  are decided, pixel-fidelity to the mock is the goal.

- `design/design_reference/` — Babel-in-browser React prototypes built
  to communicate intended look and behavior. Includes:
  * `SoftLAN Launcher.html` — entry that wires React + Babel and
    mounts the design canvas with all variants side-by-side.
  * `styles.css` — full visual spec as CSS custom properties + named
    component classes (`.topbar`, `.seg`, `.card`, `.modal`, etc.).
  * `data.jsx` — mock game catalog plus filter/sort helpers and a
    mock STORAGE record used by the storage meter.
  * `components.jsx` — reusable building blocks (Icon set, GameCover
    placeholder generator, StateChip, ActionButton, GameCard,
    SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
    StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
    SettingsDialog).
  * `launcher.jsx` — composes top bar + grid + modals into a complete
    launcher screen, in both `single`-row and `two`-row chrome
    variants.

These files are reference material, not production code. They are not
imported by the Vite/Tauri build and ship outside the frontend crate
(`crates/lanspread-tauri-deno-ts/`). They are committed so the design
intent is reviewable in-repo and surviving across implementations.

The accompanying production implementation against this spec is in
follow-up commits.

Trailer
-------
Refs: design/README.md (canonical handoff)
2026-05-19 19:59:36 +02:00
ddidderr ff35f0d95f feat(tauri): make unpack logs viewer usable for debugging
The original unpack logs window was a flat, monolithic scroll of every
unrar invocation glued together as one continuous textfield. That is
fine for a sanity check but hostile to actually finding a failed
extraction in a session with 30+ games: empty lines from unrar bloated
the view, there was no way to focus on a single game, no filtering, and
no way to narrow in on the entries that actually failed.

This rewrites the viewer to be a proper debugging surface while keeping
the backend untouched -- it still consumes the existing
`get_unpack_logs` command and `unpack-logs-updated` event.

User-visible changes:

* Empty / whitespace-only lines are stripped from stdout and stderr
  before rendering, so unrar's padding no longer drowns out real output.
* Two-pane layout: a sidebar lists every captured invocation (badge,
  archive basename, finish time); the right pane shows the selected
  entry's metadata, stdout and stderr.
* "Errors only" checkbox filters the sidebar to entries whose `success`
  flag is false (sidecar exit != 0 or one of the pre-spawn failure
  paths). This is the primary affordance for "find the unpack that
  broke".
* Regex input filters lines (not entries) -- both per-log when viewing
  one, and across the list: entries that contribute zero matching lines
  are hidden, and the remaining ones display a per-entry match counter
  next to the badge. Regex is case-insensitive; a bad pattern reddens
  the input and renders the parser error inline rather than silently
  dropping all matches.
* Prev / Next buttons plus arrow keys (and j/k) step through the
  filtered list one entry at a time, with the active row auto-scrolled
  into view. Selection is tracked by the entry's index in the full log
  ring so it survives filter toggles and live appends.

Code organization:

The component, its types, helpers (`basename`, `nonEmptyLines`,
`formatLogTime`, `isUnpackLogsView`) and its CSS are moved out of
`App.tsx` / `App.css` into a dedicated `UnpackLogsWindow.tsx` +
`UnpackLogsWindow.css` pair. The viewer has no shared state with the
main window and lives behind its own `?view=unpack-logs` route, so
keeping ~200 lines of debug-UI plumbing inside `App.tsx` was just
noise. `App.tsx` now imports `UnpackLogsWindow` and `isUnpackLogsView`
and otherwise looks the same as before.

Intentionally out of scope:

* No backend changes. The Rust side already records everything needed;
  this is purely a presentation improvement.
* No "view all logs concatenated" mode. The flat view was what we just
  replaced -- if it is ever wanted back, it can be added as a third
  pane mode.
* Regex is applied to displayed lines only, not to archive paths or
  meta. Filtering by archive name is easy enough via the basename in
  the sidebar; adding a second filter for it now would be premature.
* Logs are still process-local and capped at `MAX_UNPACK_LOGS` (100)
  in the Rust state -- unchanged from b35755f.

Test plan:

* `tsc --noEmit` and `vite build` are clean.
* Manual: trigger several successful and failed unpacks (rename one
  archive between handshake and extraction to force a canonicalize
  failure), open Unpack Logs, and verify:
    - empty lines are gone from stdout/stderr,
    - sidebar lists every entry with the right OK/FAIL badge,
    - "Errors only" hides the OK rows,
    - typing a regex narrows lines in the open entry, hides entries
      with no matches, and shows the per-entry match counts,
    - an invalid regex (e.g. `[`) reddens the field and shows the
      parser error rather than crashing,
    - arrow keys / j / k step through the filtered list and the
      active row scrolls into view,
    - new entries arriving via `unpack-logs-updated` while the window
      is open keep the current selection rather than jumping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:54:50 +02:00
ddidderr b35755f4e6 feat(tauri): add unpack logs viewer for unrar attempts
Captures stdout, stderr, exit status and start/finish timestamps for every
unrar sidecar invocation and exposes them through a dedicated "Unpack Logs"
window. Triggered by the need to debug why a particular game's archive
failed to extract -- previously the only artifact of a failed unpack was a
log line in the Tauri process stdout, which is awkward to inspect on an
end-user machine.

Implementation:

* `LanSpreadState` gains an in-memory ring buffer (`unpack_logs`) capped at
  `MAX_UNPACK_LOGS` (100). The previous monolithic `do_unrar` is split into
  `prepare_unrar_paths` and `run_unrar_sidecar` so every failure path (mkdir
  failure, canonicalize failure, non-UTF-8 destination, sidecar spawn error,
  non-zero exit) records an `UnpackLogEntry` before bailing.
* A `get_unpack_logs` Tauri command returns the current snapshot; an
  `unpack-logs-updated` event is emitted after every write so the viewer can
  refresh without polling.
* The React `App` component now routes on `?view=unpack-logs` and renders a
  dedicated `UnpackLogsWindow`. The main window opens the viewer via
  `WebviewWindow` with label `unpack-logs`; an existing window is focused
  instead of being recreated.

Capability scoping: the new window is given its own capability file
(`capabilities/unpack-logs.json`) granting only `core:default`. The main
capability is unchanged in window scope and only gains the two permissions
the main window itself needs (`core:window:allow-set-focus` to focus an
existing log window, `core:webview:allow-create-webview-window` to spawn
it). Splitting the capability keeps the log window from inheriting
`shell:allow-open`, `dialog:default` and `store:default`, which it has no
reason to use.

Known limitations (intentionally out of scope here):

* Logs are process-local; they vanish on app restart. Persistence can be
  added later if it turns out users want to inspect failures across runs.
* Entries are presented as a flat chronological list identified by archive
  path. No per-game grouping or filtering yet -- the archive filename is
  usually enough to identify the game in practice.
* The `unpack-logs-updated` event carries no payload; the viewer re-fetches
  the full snapshot on every notification. Acceptable given the 100-entry
  cap, but a payload-bearing event would be cheaper if the cap grows.

Test plan:

* `just clippy` and `just build` are clean.
* Manual: start the GUI, point it at a games directory containing at least
  one peer-hosted game, trigger an install, then click "Unpack Logs". The
  window should show one entry per unrar invocation with stdout, stderr,
  status code and timestamps; stderr/error lines render in the warning
  color. Triggering further unpacks should update the open window live via
  the `unpack-logs-updated` event without manual refresh.
* Negative path: rename or remove the archive between handshake and
  extraction to force a canonicalize failure; confirm a failed entry with
  the corresponding stderr appears in the viewer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:27:59 +02:00
49 changed files with 6257 additions and 1269 deletions
+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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>
<body>
@@ -7,6 +7,8 @@
],
"permissions": [
"core:default",
"core:window:allow-set-focus",
"core:webview:allow-create-webview-window",
"shell:allow-open",
"dialog: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,
path::{Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use eyre::bail;
@@ -40,6 +41,7 @@ struct LanSpreadState {
games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
@@ -64,19 +66,41 @@ struct GamesListPayload {
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 {
app_handle: AppHandle,
}
const MAX_UNPACK_LOGS: usize = 100;
impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
let sidecar = self.app_handle.shell().sidecar("unrar")?;
do_unrar(sidecar, archive, dest).await
let app_handle = self.app_handle.clone();
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")]
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<()> {
if let Ok(()) = std::fs::create_dir_all(dest_dir) {
if let Ok(rar_file) = rar_file.canonicalize() {
if let Ok(dest_dir) = dest_dir.canonicalize() {
let dest_dir = dest_dir
.to_str()
.ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?;
async fn do_unrar(
app_handle: &AppHandle,
sidecar: Command,
rar_file: &Path,
dest_dir: &Path,
) -> eyre::Result<()> {
let started_at_ms = now_millis();
let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?;
log::info!(
"unrar game: {} to {}",
rar_file.canonicalize()?.display(),
dest_dir
paths.archive.display(),
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(rar_file.canonicalize()?)
.arg(&paths.archive)
.arg("-y") // Assume Yes on all queries
.arg("-o") // Set overwrite mode
.arg(add_final_slash(dest_dir))
.arg(&paths.destination_arg)
.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() {
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
bail!(
"unrar failed with status {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let status_code = out.status.code();
let success = out.status.success();
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(());
}
log::error!("dest_dir canonicalize failed: {}", dest_dir.display());
} else {
log::error!("rar_file canonicalize failed: {}", rar_file.display());
Ok(())
}
async fn record_unpack_failure(
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.
@@ -1123,7 +1316,8 @@ pub fn run() {
update_game,
uninstall_game,
get_peer_count,
get_game_thumbnail
get_game_thumbnail,
get_unpack_logs
])
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
-351
View File
@@ -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;
}
+9 -879
View File
@@ -1,881 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import { load } from '@tauri-apps/plugin-store';
import "./App.css";
const FILE_STORAGE = 'launcher-settings.json';
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>
);
};
import { MainWindow } from './windows/MainWindow';
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
/**
* 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
* a single Vite build serves both.
*/
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
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 (AZ)' },
{ 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 App from "./App";
import "./styles/tokens.css";
import "./styles/launcher.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<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>
);
};
+382
View File
@@ -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 (AZ)`, `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. `232`). 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. "232"
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>
+472
View File
@@ -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 (AZ)' },
{ 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,
});
+192
View File
@@ -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: '18', 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: '18', 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: '216', 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: '415', 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: '264', 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: '264', 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: '18', 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: '232', 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: '232', 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: '232', 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: '16', 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: '18', 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: '232', 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: '232', 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: '216', 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: '216', 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: '18', 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: '2100', 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: '12', 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: '216', 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: '18', 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: '232', 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: '232', 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: '112', 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;
+112
View File
@@ -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;
+931
View File
@@ -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);
}