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:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user