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/
This commit is contained in:
@@ -1,351 +0,0 @@
|
||||
body {
|
||||
background-color: #000313;
|
||||
font-family: Arial, sans-serif;
|
||||
color: #D5DBFE;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #000313;
|
||||
z-index: 1000;
|
||||
padding-top: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1.align-center {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
margin-top: 160px; /* Adjust based on your header height */
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(to bottom, black, #000938);
|
||||
color: white;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: background 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
/* max-width: 280px; */
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: linear-gradient(to bottom, black, #3849AB);
|
||||
}
|
||||
|
||||
.item img {
|
||||
width: 280px; /* Fixed width */
|
||||
height: 200px; /* Fixed height */
|
||||
object-fit: cover;
|
||||
display: block; /* Removes any unwanted spacing */
|
||||
margin: 0 auto; /* Centers the image if container is wider */
|
||||
}
|
||||
|
||||
.item-name {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px 10px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid #4866b9;
|
||||
border-radius: 4px;
|
||||
color: #D5DBFE;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.badge.local-only {
|
||||
border-color: #8b6f2a;
|
||||
color: #f1d58a;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.size-text {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
margin-top: auto;
|
||||
margin-bottom: 2px;
|
||||
padding: 15px 30px;
|
||||
background: linear-gradient(45deg, #09305a, #37529c);
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 25px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 8px 15px rgba(0, 191, 255, 0.2);
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
||||
box-shadow: 0 20px 25px rgba(0, 191, 255, 0.6);
|
||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
||||
animation: flicker 0.2s infinite alternate;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.play-button.unavailable {
|
||||
background: linear-gradient(45deg, #330000, #550000);
|
||||
color: #ffb4b4;
|
||||
border: 1px solid #550000;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.play-button.unavailable:hover {
|
||||
background: linear-gradient(45deg, #330000, #550000);
|
||||
box-shadow: none;
|
||||
border: 1px solid #550000;
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.uninstall-button {
|
||||
align-self: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin: 6px 0 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #6c2942;
|
||||
background: #2a0714;
|
||||
color: #ffb4c8;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uninstall-button:hover {
|
||||
border-color: #ff6d9d;
|
||||
background: #4d1025;
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-directory-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.no-directory-message {
|
||||
color: #8892b0;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-directory-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
color: #D5DBFE;
|
||||
background: #000938;
|
||||
border: 1px solid #444;
|
||||
border-radius: 25px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4866b9;
|
||||
box-shadow: 0 0 10px rgba(0, 191, 255, 0.2);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.search-settings-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(45deg, #09305a, #37529c);
|
||||
color: #D5DBFE;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2);
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.settings-text {
|
||||
color: #8892b0;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.no-games-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #8892b0;
|
||||
font-size: 18px;
|
||||
padding: 40px 20px;
|
||||
margin: 20px 0;
|
||||
background: linear-gradient(to bottom, rgba(0, 9, 56, 0.3), rgba(0, 9, 56, 0.1));
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
min-height: 18px;
|
||||
margin: 8px 10px 16px;
|
||||
font-size: 0.85em;
|
||||
color: #8892b0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-info.error {
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(45deg, #09305a, #37529c);
|
||||
color: #D5DBFE;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2);
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-button.active {
|
||||
background: linear-gradient(45deg, #09305a, #4866b9);
|
||||
border: 1px solid rgba(0, 191, 255, 0.6);
|
||||
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
margin: 8px 10px 16px;
|
||||
font-size: 0.85em;
|
||||
color: #8892b0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-left {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.peer-count {
|
||||
font-weight: bold;
|
||||
color: #4866b9;
|
||||
}
|
||||
|
||||
.top-left-peer-count {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 1001;
|
||||
}
|
||||
@@ -1,911 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import "./App.css";
|
||||
import { MainWindow } from './windows/MainWindow';
|
||||
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
|
||||
|
||||
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 MainWindow = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const openUnpackLogsWindow = async () => {
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
const logWindow = new WebviewWindow('unpack-logs', {
|
||||
url: '/?view=unpack-logs',
|
||||
title: 'Unpack Logs',
|
||||
width: 900,
|
||||
height: 700,
|
||||
resizable: true,
|
||||
});
|
||||
await logWindow.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening unpack logs window:', event.payload);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error opening unpack logs window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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={() => void openUnpackLogsWindow()} className="settings-button">Unpack Logs</button>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />;
|
||||
};
|
||||
/**
|
||||
* 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,44 @@
|
||||
import { JSX, MouseEvent } from 'react';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { Game } from '../lib/types';
|
||||
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
size?: 'md' | 'lg';
|
||||
full?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
|
||||
play: <Icon.play />,
|
||||
install: <Icon.install />,
|
||||
update: <Icon.install />,
|
||||
download: <Icon.download />,
|
||||
};
|
||||
|
||||
/** Color-coded primary action: Play / Install / Update / Download / busy. */
|
||||
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
|
||||
const action = primaryActionFor(game);
|
||||
const cls = [
|
||||
'act-btn',
|
||||
`act-${action}`,
|
||||
size === 'lg' ? 'act-lg' : '',
|
||||
full ? 'act-full' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const disabled = action === 'busy' || action === 'disabled';
|
||||
|
||||
const handle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={cls} onClick={handle} disabled={disabled}>
|
||||
{ICON_FOR_ACTION[action]}
|
||||
<span>{actionLabel(game)}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
peerCount: number;
|
||||
}
|
||||
|
||||
export const Brand = ({ peerCount }: Props) => (
|
||||
<div className="brand">
|
||||
<div className="brand-mark">S</div>
|
||||
<div className="brand-name">SoftLAN</div>
|
||||
{peerCount > 0 && (
|
||||
<span
|
||||
className="brand-peers"
|
||||
title={`${peerCount} peer${peerCount === 1 ? '' : 's'} online`}
|
||||
>
|
||||
<span className="brand-peers-dot" />
|
||||
{peerCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Swatch {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: ReadonlyArray<Swatch>;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const ColorSwatchPicker = ({ value, options, onChange }: Props) => (
|
||||
<div className="swatch-row">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`swatch${value === o.value ? ' is-active' : ''}`}
|
||||
onClick={() => onChange(o.value)}
|
||||
style={{ color: o.value }}
|
||||
title={o.label}
|
||||
aria-label={o.label}
|
||||
>
|
||||
<span className="swatch-dot" style={{ background: o.value }} />
|
||||
{value === o.value && (
|
||||
<span className="swatch-check">
|
||||
<Icon.check />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { JSX, SVGProps } from 'react';
|
||||
|
||||
type Props = SVGProps<SVGSVGElement>;
|
||||
|
||||
const baseStroke: Partial<Props> = {
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
|
||||
export const Icon = {
|
||||
search: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="m13.5 13.5-3-3" />
|
||||
</svg>
|
||||
),
|
||||
play: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}>
|
||||
<path d="M4 2.5v11l10-5.5z" />
|
||||
</svg>
|
||||
),
|
||||
install: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="M8 2v8" />
|
||||
<path d="m4.5 7 3.5 3.5L11.5 7" />
|
||||
<path d="M2.5 12.5h11" />
|
||||
</svg>
|
||||
),
|
||||
download: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="M8 2v8" />
|
||||
<path d="m4.5 7 3.5 3.5L11.5 7" />
|
||||
<path d="M2.5 13.5h11" />
|
||||
</svg>
|
||||
),
|
||||
folder: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z" />
|
||||
</svg>
|
||||
),
|
||||
kebab: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}>
|
||||
<circle cx="8" cy="3.2" r="1.4" />
|
||||
<circle cx="8" cy="8" r="1.4" />
|
||||
<circle cx="8" cy="12.8" r="1.4" />
|
||||
</svg>
|
||||
),
|
||||
sort: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||
<path d="M3 4h10" />
|
||||
<path d="M4.5 8h7" />
|
||||
<path d="M6 12h4" />
|
||||
</svg>
|
||||
),
|
||||
users: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<circle cx="6" cy="6" r="2.4" />
|
||||
<path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13" />
|
||||
<circle cx="11.2" cy="5.4" r="1.8" />
|
||||
<path d="M10.4 9.8c1.7 0 3 1 3.6 2.6" />
|
||||
</svg>
|
||||
),
|
||||
close: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="m4 4 8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
),
|
||||
check: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}>
|
||||
<path d="m3 8 3.5 3.5L13 5" />
|
||||
</svg>
|
||||
),
|
||||
chevron: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="11" height="11" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||
<path d="m4 6 4 4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
trash: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<path d="M3 4.5h10" />
|
||||
<path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" />
|
||||
<path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5" />
|
||||
</svg>
|
||||
),
|
||||
games: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="22" height="22" strokeWidth={1.4} {...baseStroke} {...p}>
|
||||
<rect x="2" y="5" width="12" height="8" rx="2" />
|
||||
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
||||
</svg>
|
||||
),
|
||||
} satisfies Record<string, (p: Props) => JSX.Element>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic modal scrim + panel container. Closes on scrim click and Esc.
|
||||
* Click events inside the panel are stopped so children can decide their own
|
||||
* dismiss behaviour.
|
||||
*/
|
||||
export const Modal = ({ onClose, children, className }: Props) => {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div
|
||||
className={className ? `modal ${className}` : 'modal'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
interface Option<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<T extends string> {
|
||||
value: T;
|
||||
options: ReadonlyArray<Option<T>>;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
export const SegmentedRadio = <T extends string>({ value, options, onChange }: Props<T>) => (
|
||||
<div className="srad">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`srad-btn${value === o.value ? ' is-active' : ''}`}
|
||||
onClick={() => onChange(o.value)}
|
||||
type="button"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Game } from '../lib/types';
|
||||
import { deriveState } from '../lib/gameState';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
installed: 'Installed',
|
||||
local: 'Local',
|
||||
busy: 'Working',
|
||||
none: '',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
/** Render even for `none` (used in the detail modal). */
|
||||
showNone?: boolean;
|
||||
}
|
||||
|
||||
export const StateChip = ({ game, showNone = false }: Props) => {
|
||||
const state = deriveState(game);
|
||||
const label = LABELS[state] ?? '';
|
||||
if (!label && !showNone) return null;
|
||||
return (
|
||||
<div className="state-chip" data-state={state}>
|
||||
<span className="state-dot" />
|
||||
{label || 'Not downloaded'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const EmptyResultsState = ({ title, hint }: Props) => (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><Icon.games /></div>
|
||||
<h2 className="empty-state-title">{title}</h2>
|
||||
<p className="empty-state-hint">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface Props {
|
||||
onChooseDirectory: () => void;
|
||||
}
|
||||
|
||||
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><Icon.folder /></div>
|
||||
<h2 className="empty-state-title">Pick a game directory</h2>
|
||||
<p className="empty-state-hint">
|
||||
SoftLAN scans the folder you point it at for installable game bundles
|
||||
and tracks what your peers on the LAN have available.
|
||||
</p>
|
||||
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
||||
<Icon.folder />
|
||||
<span>Choose folder</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { JSX, KeyboardEvent } from 'react';
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
|
||||
import { GameCover } from './GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
}
|
||||
|
||||
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
const out: JSX.Element[] = [];
|
||||
filtered.forEach((p, i) => {
|
||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||
out.push(<span key={`p${i}`}>{p}</span>);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => {
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card"
|
||||
onClick={() => onOpen(game)}
|
||||
onKeyDown={onKey}
|
||||
aria-label={game.name}
|
||||
>
|
||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||
<StateChip game={game} />
|
||||
{game.peer_count > 0 && (
|
||||
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
|
||||
<Icon.users />
|
||||
<span>{game.peer_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-title" title={game.name}>{game.name}</div>
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message ?? ''}
|
||||
</div>
|
||||
<ActionButton game={game} full onClick={() => onPrimary(game)} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { coverColorsFor, titleFontSize } from '../../lib/cover';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl?: string | null;
|
||||
/** Hide the cover-bottom title overlay (used inside the detail modal hero). */
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover art. When a real thumbnail is available it's rendered as the
|
||||
* background image with the same gradient/vignette overlays as the
|
||||
* placeholder; otherwise the design's procedurally-generated gradient stands
|
||||
* in. The Bebas Neue title overlay is rendered on top of either.
|
||||
*/
|
||||
export const GameCover = ({ game, aspect, thumbnailUrl, hideTitle = false }: Props) => {
|
||||
const colors = useMemo(() => coverColorsFor(game.id), [game.id]);
|
||||
const hasThumbnail = Boolean(thumbnailUrl);
|
||||
// Real cover art already contains its own title; only burn the Bebas Neue
|
||||
// overlay onto the procedurally-generated placeholder.
|
||||
const showOverlayTitle = !hideTitle && !hasThumbnail;
|
||||
const titleStyle: CSSProperties = {
|
||||
fontSize: titleFontSize(game.name, aspect),
|
||||
textShadow: `0 4px 16px ${colors.c2}aa, 0 1px 0 rgba(0,0,0,.3)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cover">
|
||||
{hasThumbnail ? (
|
||||
<img className="cover-image" src={thumbnailUrl!} alt="" loading="lazy" />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="cover-base"
|
||||
style={{
|
||||
background: `linear-gradient(${colors.angle}deg, ${colors.c1} 0%, ${colors.c2} 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="cover-blob"
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at ${colors.blobX}% ${colors.blobY}%, ${colors.accent}38, transparent 55%)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="cover-grain" />
|
||||
{showOverlayTitle && (
|
||||
<div className="cover-titlewrap">
|
||||
<div className="cover-title" style={titleStyle}>
|
||||
{game.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-vignette" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
|
||||
import { GameCard } from './GameCard';
|
||||
|
||||
interface Props {
|
||||
games: Game[];
|
||||
aspect: CoverAspect;
|
||||
getThumbnail: (id: string) => string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
}
|
||||
|
||||
export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => (
|
||||
<div className="grid">
|
||||
{games.map(g => (
|
||||
<GameCard
|
||||
key={g.id}
|
||||
game={g}
|
||||
aspect={aspect}
|
||||
thumbnailUrl={getThumbnail(g.id)}
|
||||
onOpen={onOpen}
|
||||
onPrimary={onPrimary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
interface Props {
|
||||
shown: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const ResultsBar = ({ shown, total }: Props) => (
|
||||
<div className="results-bar">
|
||||
<div className="results-count">
|
||||
Showing <strong>{shown}</strong> of {total} games
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Modal } from '../Modal';
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from '../grid/GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { deriveState } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
thumbnailUrl: string | null;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
}
|
||||
|
||||
const tagsFromGame = (game: Game): string[] => {
|
||||
const tags: string[] = [];
|
||||
if (game.genre) tags.push(game.genre);
|
||||
if (game.publisher) tags.push(game.publisher);
|
||||
if (game.release_year) tags.push(game.release_year);
|
||||
return tags;
|
||||
};
|
||||
|
||||
const statusLabelFor = (game: Game): string => {
|
||||
switch (deriveState(game)) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Downloaded';
|
||||
case 'busy': return 'Working…';
|
||||
case 'none': return 'Not downloaded';
|
||||
}
|
||||
};
|
||||
|
||||
export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUninstall }: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||
<Icon.close />
|
||||
</button>
|
||||
<div className="modal-hero">
|
||||
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
||||
<div className="modal-hero-fade" />
|
||||
<div className="modal-hero-text">
|
||||
{tags.length > 0 && (
|
||||
<div className="modal-tags">
|
||||
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="modal-title">{game.name}</h2>
|
||||
</div>
|
||||
<div className="modal-state">
|
||||
<StateChip game={game} showNone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-meta">
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Size</div>
|
||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Players</div>
|
||||
<div className="meta-value">
|
||||
<Icon.users /> {formatPlayers(game.max_players)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{statusLabelFor(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.description && (
|
||||
<p className="modal-desc">{game.description}</p>
|
||||
)}
|
||||
|
||||
{game.status_message && (
|
||||
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton game={game} size="lg" onClick={() => onPrimary(game)} />
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onUninstall(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Uninstall</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Modal } from '../Modal';
|
||||
import { Icon } from '../Icon';
|
||||
import { ColorSwatchPicker } from '../ColorSwatchPicker';
|
||||
import { SegmentedRadio } from '../SegmentedRadio';
|
||||
|
||||
import {
|
||||
ACCENT_OPTIONS,
|
||||
ASPECT_OPTIONS,
|
||||
BG_OPTIONS,
|
||||
DENSITY_OPTIONS,
|
||||
UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
|
||||
interface Props {
|
||||
settings: UISettings;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Row = ({ label, hint, children }: RowProps) => (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-info">
|
||||
<div className="settings-row-label">{label}</div>
|
||||
<div className="settings-row-hint">{hint}</div>
|
||||
</div>
|
||||
<div className="settings-row-control">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
<div className="settings-head">
|
||||
<h2>Settings</h2>
|
||||
<button
|
||||
className="modal-close settings-close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
<ColorSwatchPicker
|
||||
value={settings.accent}
|
||||
options={ACCENT_OPTIONS}
|
||||
onChange={(v) => onChange('accent', v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Background" hint="Backdrop behind the library">
|
||||
<SegmentedRadio
|
||||
value={settings.bg}
|
||||
options={BG_OPTIONS}
|
||||
onChange={(v) => onChange('bg', v)}
|
||||
/>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Library</div>
|
||||
<Row label="Grid density" hint="How tightly cards are packed">
|
||||
<SegmentedRadio
|
||||
value={settings.density}
|
||||
options={DENSITY_OPTIONS}
|
||||
onChange={(v) => onChange('density', v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Cover aspect" hint="Shape of the cover art on each card">
|
||||
<SegmentedRadio
|
||||
value={settings.aspect}
|
||||
options={ASPECT_OPTIONS}
|
||||
onChange={(v) => onChange('aspect', v)}
|
||||
/>
|
||||
</Row>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="settings-foot">
|
||||
<button type="button" className="settings-done" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Icon } from '../Icon';
|
||||
import { truncatePath } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const DirectoryButton = ({ path, onClick }: Props) => (
|
||||
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
|
||||
<Icon.folder />
|
||||
<span className="dirbtn-label">Game directory</span>
|
||||
<span className="dirbtn-path">
|
||||
{path ? truncatePath(path) : 'choose…'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
export type KebabItem =
|
||||
| { kind: 'item'; label: string; onClick: () => void }
|
||||
| { kind: 'separator' };
|
||||
|
||||
interface Props {
|
||||
items: ReadonlyArray<KebabItem>;
|
||||
}
|
||||
|
||||
export const KebabMenu = ({ items }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="kebab" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="kebab-btn"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="More"
|
||||
>
|
||||
<Icon.kebab />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="kebab-menu">
|
||||
{items.map((it, i) =>
|
||||
it.kind === 'separator' ? (
|
||||
<div key={i} className="kebab-sep" />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick();
|
||||
}}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
|
||||
* when the user is already typing into another input or textarea.
|
||||
*/
|
||||
export const SearchField = ({ value, onChange }: Props) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== '/') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<Icon.search />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search games"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="search-kbd">/</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
import { GameFilter } from '../../lib/types';
|
||||
|
||||
interface Tab {
|
||||
key: GameFilter;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: ReadonlyArray<Tab> = [
|
||||
{ key: 'all', label: 'All Games' },
|
||||
{ key: 'local', label: 'Local' },
|
||||
{ key: 'installed', label: 'Installed' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: GameFilter;
|
||||
onChange: (value: GameFilter) => void;
|
||||
counts: FilterCounts;
|
||||
}
|
||||
|
||||
/** Pill-style filter with an animated thumb that slides under the active tab. */
|
||||
export const SegmentedFilters = ({ value, onChange, counts }: Props) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [thumb, setThumb] = useState({ left: 0, width: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const active = containerRef.current.querySelector<HTMLElement>(`[data-key="${value}"]`);
|
||||
if (active) setThumb({ left: active.offsetLeft, width: active.offsetWidth });
|
||||
}, [value, counts.all, counts.local, counts.installed]);
|
||||
|
||||
return (
|
||||
<div className="seg" ref={containerRef}>
|
||||
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width }} />
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
data-key={t.key}
|
||||
type="button"
|
||||
className={`seg-btn${value === t.key ? ' is-active' : ''}`}
|
||||
onClick={() => onChange(t.key)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
<span className="seg-count">{counts[t.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import { GameSort } from '../../lib/types';
|
||||
|
||||
const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [
|
||||
{ key: 'az', label: 'Name (A–Z)' },
|
||||
{ key: 'size', label: 'Size (largest)' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: GameSort;
|
||||
onChange: (value: GameSort) => void;
|
||||
}
|
||||
|
||||
export const SortMenu = ({ value, onChange }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, [open]);
|
||||
|
||||
const current = OPTIONS.find(o => o.key === value) ?? OPTIONS[0];
|
||||
|
||||
return (
|
||||
<div className="sort" ref={ref}>
|
||||
<button className="sort-btn" type="button" onClick={() => setOpen(o => !o)}>
|
||||
<Icon.sort />
|
||||
<span>Sort: <strong>{current.label}</strong></span>
|
||||
<Icon.chevron />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="sort-menu">
|
||||
{OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(o.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="sort-check">
|
||||
{o.key === value && <Icon.check />}
|
||||
</span>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Brand } from '../Brand';
|
||||
import { SegmentedFilters } from './SegmentedFilters';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SortMenu } from './SortMenu';
|
||||
import { DirectoryButton } from './DirectoryButton';
|
||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
import { GameFilter, GameSort } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
peerCount: number;
|
||||
filter: GameFilter;
|
||||
setFilter: (value: GameFilter) => void;
|
||||
counts: FilterCounts;
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
sort: GameSort;
|
||||
setSort: (value: GameSort) => void;
|
||||
gameDir: string;
|
||||
onPickDirectory: () => void;
|
||||
kebabItems: ReadonlyArray<KebabItem>;
|
||||
}
|
||||
|
||||
export const TopBar = ({
|
||||
peerCount,
|
||||
filter,
|
||||
setFilter,
|
||||
counts,
|
||||
query,
|
||||
setQuery,
|
||||
sort,
|
||||
setSort,
|
||||
gameDir,
|
||||
onPickDirectory,
|
||||
kebabItems,
|
||||
}: Props) => (
|
||||
<header className="topbar">
|
||||
<Brand peerCount={peerCount} />
|
||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
<SearchField value={query} onChange={setQuery} />
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
|
||||
<KebabMenu items={kebabItems} />
|
||||
</header>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { UseGamesResult } from './useGames';
|
||||
|
||||
export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||
* / `uninstall_game` commands. For install + update we mark the game as
|
||||
* "checking peers" up-front through the games hook so the UI doesn't have to
|
||||
* wait for the first backend event.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('run_game', { id });
|
||||
} catch (err) {
|
||||
console.error('run_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('install_game', { id });
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('install_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('update_game', { id });
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('update_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
const uninstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('uninstall_game', { id });
|
||||
} catch (err) {
|
||||
console.error('uninstall_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall };
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
||||
|
||||
/**
|
||||
* Owns the user's selected game directory. Hydrates from the persistent store
|
||||
* on mount, writes back on every change, and pushes the value to the Tauri
|
||||
* backend so it can scan/rescan.
|
||||
*/
|
||||
export const useGameDirectory = () => {
|
||||
const [gameDir, setGameDir] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<string>(GAME_DIR_KEY);
|
||||
if (saved && !cancelled) setGameDir(saved);
|
||||
} catch (err) {
|
||||
console.error('Failed to load game directory:', err);
|
||||
}
|
||||
};
|
||||
void hydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameDir) return;
|
||||
const sync = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
await store.set(GAME_DIR_KEY, gameDir);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist game directory:', err);
|
||||
}
|
||||
};
|
||||
void sync();
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to push game directory to backend:', err),
|
||||
);
|
||||
}, [gameDir]);
|
||||
|
||||
const rescan = useCallback(() => {
|
||||
if (!gameDir) return;
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to rescan game directory:', err),
|
||||
);
|
||||
}, [gameDir]);
|
||||
|
||||
return { gameDir, setGameDir, rescan };
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import {
|
||||
Game,
|
||||
GamesListPayload,
|
||||
InstallStatus,
|
||||
StatusLevel,
|
||||
} from '../lib/types';
|
||||
import {
|
||||
activeStatusById,
|
||||
mergeGameUpdate,
|
||||
normalizeGamesListPayload,
|
||||
} from '../lib/gameState';
|
||||
|
||||
const CHECKING_PEERS_TIMEOUT_MS = 5000;
|
||||
|
||||
interface PendingPatch {
|
||||
install_status?: InstallStatus;
|
||||
installed?: boolean;
|
||||
status_message?: string;
|
||||
status_level?: StatusLevel | undefined;
|
||||
clearStatus?: boolean;
|
||||
}
|
||||
|
||||
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
||||
let next: Game = { ...game };
|
||||
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
||||
if (patch.installed !== undefined) next.installed = patch.installed;
|
||||
if (patch.clearStatus) {
|
||||
next.status_message = undefined;
|
||||
next.status_level = undefined;
|
||||
}
|
||||
if (patch.status_message !== undefined) {
|
||||
next.status_message = patch.status_message;
|
||||
next.status_level = patch.status_level;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the games list and reflects every backend event (download/install/
|
||||
* uninstall lifecycle, peer count) into local React state. Returns a
|
||||
* fire-and-forget `markChecking` helper so action calls can immediately show a
|
||||
* "Checking peers…" state with an automatic fall-back if the backend never
|
||||
* emits a follow-up event.
|
||||
*/
|
||||
export interface UseGamesResult {
|
||||
games: Game[];
|
||||
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
||||
totalPeerCount: number;
|
||||
requestGames: () => Promise<void>;
|
||||
markChecking: (id: string) => void;
|
||||
cancelChecking: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||
const checkingTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const rescanRef = useRef(rescanGameDir);
|
||||
rescanRef.current = rescanGameDir;
|
||||
|
||||
const cancelChecking = useCallback((id: string) => {
|
||||
const t = checkingTimeouts.current[id];
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t);
|
||||
delete checkingTimeouts.current[id];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markChecking = useCallback((id: string) => {
|
||||
cancelChecking(id);
|
||||
setGames(prev => prev.map(item =>
|
||||
item.id === id
|
||||
? { ...item, install_status: InstallStatus.CheckingPeers }
|
||||
: item,
|
||||
));
|
||||
checkingTimeouts.current[id] = setTimeout(() => {
|
||||
setGames(prev => prev.map(item => {
|
||||
if (item.id !== id || item.install_status !== InstallStatus.CheckingPeers) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
install_status: item.installed
|
||||
? InstallStatus.Installed
|
||||
: InstallStatus.NotInstalled,
|
||||
status_message: 'No peers currently have this game.',
|
||||
status_level: 'error',
|
||||
};
|
||||
}));
|
||||
delete checkingTimeouts.current[id];
|
||||
}, CHECKING_PEERS_TIMEOUT_MS);
|
||||
}, [cancelChecking]);
|
||||
|
||||
const requestGames = useCallback(async () => {
|
||||
try {
|
||||
await invoke('request_games');
|
||||
} catch (err) {
|
||||
console.error('request_games failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisteners: UnlistenFn[] = [];
|
||||
let cancelled = false;
|
||||
|
||||
const updateById = (id: string, patch: PendingPatch) => {
|
||||
setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item));
|
||||
};
|
||||
|
||||
const handleErrorEvent = (
|
||||
id: string,
|
||||
message: string,
|
||||
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
||||
) => {
|
||||
cancelChecking(id);
|
||||
setGames(prev => prev.map(item => item.id === id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed
|
||||
? InstallStatus.Installed
|
||||
: InstallStatus.NotInstalled,
|
||||
status_message: message,
|
||||
status_level: 'error',
|
||||
}
|
||||
: item));
|
||||
if (triggerRescan) rescanRef.current();
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
unlisteners.push(await listen('games-list-updated', (event) => {
|
||||
const payload = normalizeGamesListPayload(
|
||||
event.payload as GamesListPayload | Game[],
|
||||
);
|
||||
const activeStatuses = activeStatusById(payload.active_operations);
|
||||
const hasAuthoritative = payload.active_operations !== undefined;
|
||||
setGames(prev => {
|
||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||
return payload.games.map(game => mergeGameUpdate(
|
||||
game,
|
||||
previousById.get(game.id),
|
||||
activeStatuses.get(game.id),
|
||||
hasAuthoritative,
|
||||
));
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-begin', (e) => {
|
||||
const id = e.payload as string;
|
||||
cancelChecking(id);
|
||||
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-finished', (e) => {
|
||||
const id = e.payload as string;
|
||||
cancelChecking(id);
|
||||
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
||||
triggerRescan: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-peers-gone', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
|
||||
triggerRescan: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-no-peers', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-install-begin', (e) => {
|
||||
const id = e.payload as string;
|
||||
cancelChecking(id);
|
||||
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-install-finished', (e) => {
|
||||
const id = e.payload as string;
|
||||
cancelChecking(id);
|
||||
updateById(id, {
|
||||
install_status: InstallStatus.Installed,
|
||||
installed: true,
|
||||
clearStatus: true,
|
||||
});
|
||||
rescanRef.current();
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-install-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-uninstall-begin', (e) => {
|
||||
updateById(e.payload as string, {
|
||||
install_status: InstallStatus.Uninstalling,
|
||||
clearStatus: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-uninstall-finished', (e) => {
|
||||
updateById(e.payload as string, {
|
||||
install_status: InstallStatus.NotInstalled,
|
||||
installed: false,
|
||||
clearStatus: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('peer-count-updated', (e) => {
|
||||
setTotalPeerCount(e.payload as number);
|
||||
}));
|
||||
|
||||
if (!cancelled) {
|
||||
await invoke('request_games').catch(err =>
|
||||
console.error('request_games failed:', err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to register game listeners:', err);
|
||||
}
|
||||
};
|
||||
|
||||
void register();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisteners.forEach(fn => fn());
|
||||
Object.values(checkingTimeouts.current).forEach(clearTimeout);
|
||||
checkingTimeouts.current = {};
|
||||
};
|
||||
}, [cancelChecking]);
|
||||
|
||||
return {
|
||||
games,
|
||||
setGames,
|
||||
totalPeerCount,
|
||||
requestGames,
|
||||
markChecking,
|
||||
cancelChecking,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { GameFilter, GameSort } from '../lib/types';
|
||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||
|
||||
export type Density = 'compact' | 'normal' | 'large';
|
||||
export type CoverAspect = 'box' | 'square' | 'banner';
|
||||
export type BackgroundStyle = 'flat' | 'gradient' | 'animated';
|
||||
|
||||
export interface UISettings {
|
||||
accent: string;
|
||||
bg: BackgroundStyle;
|
||||
density: Density;
|
||||
aspect: CoverAspect;
|
||||
sort: GameSort;
|
||||
filter: GameFilter;
|
||||
}
|
||||
|
||||
export const ACCENT_OPTIONS = [
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
{ value: '#22d3ee', label: 'Cyan' },
|
||||
{ value: '#a855f7', label: 'Violet' },
|
||||
{ value: '#22c55e', label: 'Green' },
|
||||
{ value: '#f59e0b', label: 'Amber' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
] as const;
|
||||
|
||||
export const BG_OPTIONS: ReadonlyArray<{ value: BackgroundStyle; label: string }> = [
|
||||
{ value: 'flat', label: 'Flat' },
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
{ value: 'animated', label: 'Animated' },
|
||||
];
|
||||
|
||||
export const DENSITY_OPTIONS: ReadonlyArray<{ value: Density; label: string }> = [
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
];
|
||||
|
||||
export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }> = [
|
||||
{ value: 'box', label: 'Box-art' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTINGS: UISettings = {
|
||||
accent: '#3b82f6',
|
||||
bg: 'gradient',
|
||||
density: 'normal',
|
||||
aspect: 'box',
|
||||
sort: 'status',
|
||||
filter: 'local',
|
||||
};
|
||||
|
||||
const sanitize = (raw: Partial<UISettings> | undefined): UISettings => ({
|
||||
accent: raw?.accent ?? DEFAULT_SETTINGS.accent,
|
||||
bg: raw?.bg ?? DEFAULT_SETTINGS.bg,
|
||||
density: raw?.density ?? DEFAULT_SETTINGS.density,
|
||||
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
|
||||
sort: raw?.sort ?? DEFAULT_SETTINGS.sort,
|
||||
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
|
||||
});
|
||||
|
||||
export interface UseSettings {
|
||||
settings: UISettings;
|
||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads UI preferences from the Tauri persistent store once on mount and
|
||||
* writes every change back through it. Components only see a synchronous
|
||||
* `settings` snapshot; persistence is fire-and-forget.
|
||||
*/
|
||||
export const useSettings = (): UseSettings => {
|
||||
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<Partial<UISettings>>(UI_SETTINGS_KEY);
|
||||
if (!cancelled) {
|
||||
setSettings(sanitize(saved ?? undefined));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load UI settings:', err);
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
};
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
|
||||
setSettings(prev => {
|
||||
const next = { ...prev, [key]: value };
|
||||
void persist(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { settings, set, ready };
|
||||
};
|
||||
|
||||
const persist = async (settings: UISettings): Promise<void> => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
await store.set(UI_SETTINGS_KEY, settings);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist UI settings:', err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
|
||||
* known; returns the empty string when the backend has nothing for the id, so
|
||||
* callers can fall back to the placeholder cover art.
|
||||
*/
|
||||
export const useThumbnails = () => {
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
||||
const pending = useRef<Set<string>>(new Set());
|
||||
|
||||
const get = useCallback((id: string): string | null => {
|
||||
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
|
||||
if (pending.current.has(id)) return null;
|
||||
pending.current.add(id);
|
||||
invoke<string>('get_game_thumbnail', { gameId: id })
|
||||
.then(url => {
|
||||
pending.current.delete(id);
|
||||
setThumbnails(prev => new Map(prev).set(id, url));
|
||||
})
|
||||
.catch(() => {
|
||||
pending.current.delete(id);
|
||||
setThumbnails(prev => new Map(prev).set(id, ''));
|
||||
});
|
||||
return null;
|
||||
}, [thumbnails]);
|
||||
|
||||
return { get };
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Stable gradient + accent derived from a game id. Used as cover-art fallback
|
||||
* when the backend has no thumbnail for a game.
|
||||
*/
|
||||
export interface CoverColors {
|
||||
c1: string;
|
||||
c2: string;
|
||||
accent: string;
|
||||
angle: number;
|
||||
blobX: number;
|
||||
blobY: number;
|
||||
}
|
||||
|
||||
const PALETTE: Array<Pick<CoverColors, 'c1' | 'c2' | 'accent'>> = [
|
||||
{ c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24' },
|
||||
{ c1: '#1e40af', c2: '#0c1f3a', accent: '#22d3ee' },
|
||||
{ c1: '#15803d', c2: '#052e16', accent: '#fef08a' },
|
||||
{ c1: '#7f1d1d', c2: '#0a0a0a', accent: '#f97316' },
|
||||
{ c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b' },
|
||||
{ c1: '#a16207', c2: '#422006', accent: '#fde047' },
|
||||
{ c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee' },
|
||||
{ c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee' },
|
||||
{ c1: '#064e3b', c2: '#020617', accent: '#34d399' },
|
||||
{ c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee' },
|
||||
];
|
||||
|
||||
const hash = (id: string): number => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return h;
|
||||
};
|
||||
|
||||
export const coverColorsFor = (id: string): CoverColors => {
|
||||
const h = hash(id);
|
||||
const base = PALETTE[h % PALETTE.length];
|
||||
return {
|
||||
...base,
|
||||
angle: 110 + (h % 60),
|
||||
blobX: 60 + (h % 30),
|
||||
blobY: 10 + ((h * 7) % 30),
|
||||
};
|
||||
};
|
||||
|
||||
export const titleFontSize = (
|
||||
title: string,
|
||||
aspect: 'box' | 'square' | 'banner',
|
||||
): number => {
|
||||
const len = title.length;
|
||||
if (aspect === 'banner' || aspect === 'square') {
|
||||
if (len > 22) return 18;
|
||||
if (len > 14) return 22;
|
||||
return 28;
|
||||
}
|
||||
if (len > 26) return 15;
|
||||
if (len > 20) return 17;
|
||||
if (len > 14) return 21;
|
||||
return 26;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`;
|
||||
if (bytes >= MB) return `${(bytes / MB).toFixed(0)} MB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an ETI version stamp (YYYYMMDD) for display. Falls back to the raw
|
||||
* string when it doesn't fit the expected shape.
|
||||
*/
|
||||
export const formatEtiVersion = (raw: string | undefined): string => {
|
||||
if (!raw) return '—';
|
||||
if (raw.length === 8 && /^\d{8}$/.test(raw)) {
|
||||
return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6, 8)}`;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
|
||||
export const truncatePath = (path: string, max = 36): string =>
|
||||
path.length > max ? `…${path.slice(-(max - 1))}` : path;
|
||||
|
||||
export const formatPlayers = (max?: number): string => {
|
||||
if (!max || max <= 0) return '—';
|
||||
return max === 1 ? '1' : `1–${max}`;
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
DerivedState,
|
||||
Game,
|
||||
GameFilter,
|
||||
GameSort,
|
||||
GamesListPayload,
|
||||
InstallStatus,
|
||||
} from './types';
|
||||
|
||||
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.CheckingPeers,
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
export const isInProgress = (status: InstallStatus): boolean =>
|
||||
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||
|
||||
const isReconciledOperationStatus = (status: InstallStatus): boolean =>
|
||||
RECONCILED_OPERATION_STATUSES.has(status);
|
||||
|
||||
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
||||
switch (op) {
|
||||
case ActiveOperationKind.Downloading:
|
||||
return InstallStatus.Downloading;
|
||||
case ActiveOperationKind.Installing:
|
||||
case ActiveOperationKind.Updating:
|
||||
return InstallStatus.Installing;
|
||||
case ActiveOperationKind.Uninstalling:
|
||||
return InstallStatus.Uninstalling;
|
||||
}
|
||||
};
|
||||
|
||||
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
|
||||
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
|
||||
|
||||
export const normalizeGamesListPayload = (
|
||||
payload: GamesListPayload | Game[],
|
||||
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
||||
|
||||
/**
|
||||
* Reconcile a freshly received backend snapshot of a game with our prior
|
||||
* locally-tracked install status. Keeps in-progress operations visible across
|
||||
* snapshots that don't yet reflect the running operation.
|
||||
*/
|
||||
export const mergeGameUpdate = (
|
||||
incoming: Game,
|
||||
previous?: Game,
|
||||
activeStatus?: InstallStatus,
|
||||
hasAuthoritativeSnapshot = false,
|
||||
): Game => {
|
||||
let installStatus = InstallStatus.NotInstalled;
|
||||
if (activeStatus !== undefined) {
|
||||
installStatus = activeStatus;
|
||||
} else if (incoming.installed) {
|
||||
installStatus = InstallStatus.Installed;
|
||||
} else if (
|
||||
previous
|
||||
&& isInProgress(previous.install_status)
|
||||
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
||||
) {
|
||||
installStatus = previous.install_status;
|
||||
}
|
||||
|
||||
const localStateChanged = previous !== undefined
|
||||
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
||||
const activeStateReconciled = hasAuthoritativeSnapshot
|
||||
&& (activeStatus !== undefined
|
||||
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
||||
const clearStatus = localStateChanged || activeStateReconciled;
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
availability: incoming.availability,
|
||||
install_status: installStatus,
|
||||
status_message: clearStatus ? undefined : previous?.status_message,
|
||||
status_level: clearStatus ? undefined : previous?.status_level,
|
||||
peer_count: incoming.peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Visual card state — used for state chip color and action button styling. */
|
||||
export const deriveState = (game: Game): DerivedState => {
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (game.installed) return 'installed';
|
||||
if (game.downloaded) return 'local';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const isUnavailable = (game: Game): boolean =>
|
||||
!game.installed
|
||||
&& !game.downloaded
|
||||
&& game.peer_count === 0
|
||||
&& game.install_status === InstallStatus.NotInstalled;
|
||||
|
||||
export const needsUpdate = (game: Game): boolean => {
|
||||
if (!game.installed) return false;
|
||||
const peer = game.eti_game_version;
|
||||
const local = game.local_version;
|
||||
if (!local && peer) return true;
|
||||
if (local && peer) {
|
||||
const l = parseInt(local, 10);
|
||||
const p = parseInt(peer, 10);
|
||||
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** What pressing the card's main action button should do, given the state. */
|
||||
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
||||
|
||||
export const primaryActionFor = (game: Game): PrimaryAction => {
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (isUnavailable(game)) return 'disabled';
|
||||
if (!game.installed) return game.downloaded ? 'install' : 'download';
|
||||
if (needsUpdate(game)) return 'update';
|
||||
return 'play';
|
||||
};
|
||||
|
||||
export const inProgressLabel = (status: InstallStatus): string | undefined => {
|
||||
switch (status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
return 'Checking peers…';
|
||||
case InstallStatus.Downloading:
|
||||
return 'Downloading…';
|
||||
case InstallStatus.Installing:
|
||||
return 'Installing…';
|
||||
case InstallStatus.Uninstalling:
|
||||
return 'Uninstalling…';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const actionLabel = (game: Game): string => {
|
||||
const busy = inProgressLabel(game.install_status);
|
||||
if (busy) return busy;
|
||||
if (isUnavailable(game)) return 'Unavailable';
|
||||
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
|
||||
if (needsUpdate(game)) return 'Update';
|
||||
return 'Play';
|
||||
};
|
||||
|
||||
/** Counts shown on filter pills. */
|
||||
export interface FilterCounts {
|
||||
all: number;
|
||||
local: number;
|
||||
installed: number;
|
||||
}
|
||||
|
||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||
all: games.length,
|
||||
local: games.filter(g => g.installed || g.downloaded).length,
|
||||
installed: games.filter(g => g.installed).length,
|
||||
});
|
||||
|
||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
return game.installed || game.downloaded;
|
||||
case 'installed':
|
||||
return game.installed;
|
||||
case 'all':
|
||||
return game.installed || game.downloaded || game.peer_count > 0;
|
||||
}
|
||||
};
|
||||
|
||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||
busy: 0,
|
||||
installed: 1,
|
||||
local: 2,
|
||||
none: 3,
|
||||
};
|
||||
|
||||
const compareByState = (a: Game, b: Game): number => {
|
||||
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
|
||||
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||
};
|
||||
|
||||
export const applyFilterAndSort = (
|
||||
games: Game[],
|
||||
filter: GameFilter,
|
||||
sort: GameSort,
|
||||
query: string,
|
||||
): Game[] => {
|
||||
let list = games.filter(g => matchesFilter(g, filter));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(g =>
|
||||
g.name.toLowerCase().includes(q)
|
||||
|| (g.genre?.toLowerCase().includes(q) ?? false)
|
||||
|| (g.publisher?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}
|
||||
switch (sort) {
|
||||
case 'az':
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
case 'size':
|
||||
return [...list].sort((a, b) => b.size - a.size);
|
||||
case 'status':
|
||||
return [...list].sort(compareByState);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/** File names + keys for the @tauri-apps/plugin-store-backed persistent state. */
|
||||
|
||||
export const SETTINGS_FILE = 'launcher-settings.json';
|
||||
export const GAME_DIR_KEY = 'game-directory';
|
||||
export const UI_SETTINGS_KEY = 'ui-settings';
|
||||
|
||||
export const SETTINGS_FILE_OPTIONS = {
|
||||
autoSave: true,
|
||||
defaults: {
|
||||
[GAME_DIR_KEY]: '',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
export enum InstallStatus {
|
||||
NotInstalled = 'NotInstalled',
|
||||
CheckingPeers = 'CheckingPeers',
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Uninstalling = 'Uninstalling',
|
||||
Installed = 'Installed',
|
||||
}
|
||||
|
||||
export enum GameAvailability {
|
||||
Ready = 'Ready',
|
||||
LocalOnly = 'LocalOnly',
|
||||
}
|
||||
|
||||
export enum ActiveOperationKind {
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Updating = 'Updating',
|
||||
Uninstalling = 'Uninstalling',
|
||||
}
|
||||
|
||||
export type StatusLevel = 'info' | 'error';
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
||||
thumbnail?: Uint8Array | number[];
|
||||
downloaded: boolean;
|
||||
installed: boolean;
|
||||
availability: GameAvailability;
|
||||
install_status: InstallStatus;
|
||||
eti_game_version?: string;
|
||||
local_version?: string;
|
||||
/** Optional richer metadata surfaced by the backend. */
|
||||
release_year?: string;
|
||||
publisher?: string;
|
||||
max_players?: number;
|
||||
version?: string;
|
||||
genre?: string;
|
||||
status_message?: string;
|
||||
status_level?: StatusLevel;
|
||||
peer_count: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
id: string;
|
||||
operation: ActiveOperationKind;
|
||||
}
|
||||
|
||||
export interface GamesListPayload {
|
||||
games: Game[];
|
||||
active_operations?: ActiveOperation[];
|
||||
}
|
||||
|
||||
/** Library filter chip — what subset of the catalog to show. */
|
||||
export type GameFilter = 'all' | 'local' | 'installed';
|
||||
|
||||
/** Library sort order. */
|
||||
export type GameSort = 'az' | 'size' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from install/download flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'none' | 'busy';
|
||||
@@ -1,5 +1,8 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/launcher.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<App />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user