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:
2026-05-19 20:12:57 +02:00
parent 27c71978d2
commit 640214ec38
38 changed files with 3329 additions and 1259 deletions
@@ -0,0 +1,44 @@
import { JSX, MouseEvent } from 'react';
import { Icon } from './Icon';
import { Game } from '../lib/types';
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
interface Props {
game: Game;
size?: 'md' | 'lg';
full?: boolean;
onClick: () => void;
}
const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
play: <Icon.play />,
install: <Icon.install />,
update: <Icon.install />,
download: <Icon.download />,
};
/** Color-coded primary action: Play / Install / Update / Download / busy. */
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
const action = primaryActionFor(game);
const cls = [
'act-btn',
`act-${action}`,
size === 'lg' ? 'act-lg' : '',
full ? 'act-full' : '',
].filter(Boolean).join(' ');
const disabled = action === 'busy' || action === 'disabled';
const handle = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (disabled) return;
onClick();
};
return (
<button className={cls} onClick={handle} disabled={disabled}>
{ICON_FOR_ACTION[action]}
<span>{actionLabel(game)}</span>
</button>
);
};
@@ -0,0 +1,19 @@
interface Props {
peerCount: number;
}
export const Brand = ({ peerCount }: Props) => (
<div className="brand">
<div className="brand-mark">S</div>
<div className="brand-name">SoftLAN</div>
{peerCount > 0 && (
<span
className="brand-peers"
title={`${peerCount} peer${peerCount === 1 ? '' : 's'} online`}
>
<span className="brand-peers-dot" />
{peerCount}
</span>
)}
</div>
);
@@ -0,0 +1,35 @@
import { Icon } from './Icon';
interface Swatch {
value: string;
label: string;
}
interface Props {
value: string;
options: ReadonlyArray<Swatch>;
onChange: (value: string) => void;
}
export const ColorSwatchPicker = ({ value, options, onChange }: Props) => (
<div className="swatch-row">
{options.map(o => (
<button
key={o.value}
type="button"
className={`swatch${value === o.value ? ' is-active' : ''}`}
onClick={() => onChange(o.value)}
style={{ color: o.value }}
title={o.label}
aria-label={o.label}
>
<span className="swatch-dot" style={{ background: o.value }} />
{value === o.value && (
<span className="swatch-check">
<Icon.check />
</span>
)}
</button>
))}
</div>
);
@@ -0,0 +1,93 @@
import { JSX, SVGProps } from 'react';
type Props = SVGProps<SVGSVGElement>;
const baseStroke: Partial<Props> = {
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
export const Icon = {
search: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.6} {...baseStroke} {...p}>
<circle cx="7" cy="7" r="5" />
<path d="m13.5 13.5-3-3" />
</svg>
),
play: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}>
<path d="M4 2.5v11l10-5.5z" />
</svg>
),
install: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" />
<path d="m4.5 7 3.5 3.5L11.5 7" />
<path d="M2.5 12.5h11" />
</svg>
),
download: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" />
<path d="m4.5 7 3.5 3.5L11.5 7" />
<path d="M2.5 13.5h11" />
</svg>
),
folder: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z" />
</svg>
),
kebab: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}>
<circle cx="8" cy="3.2" r="1.4" />
<circle cx="8" cy="8" r="1.4" />
<circle cx="8" cy="12.8" r="1.4" />
</svg>
),
sort: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="M3 4h10" />
<path d="M4.5 8h7" />
<path d="M6 12h4" />
</svg>
),
users: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.5} {...baseStroke} {...p}>
<circle cx="6" cy="6" r="2.4" />
<path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13" />
<circle cx="11.2" cy="5.4" r="1.8" />
<path d="M10.4 9.8c1.7 0 3 1 3.6 2.6" />
</svg>
),
close: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
),
check: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}>
<path d="m3 8 3.5 3.5L13 5" />
</svg>
),
chevron: (p: Props) => (
<svg viewBox="0 0 16 16" width="11" height="11" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="m4 6 4 4 4-4" />
</svg>
),
trash: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M3 4.5h10" />
<path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" />
<path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5" />
</svg>
),
games: (p: Props) => (
<svg viewBox="0 0 16 16" width="22" height="22" strokeWidth={1.4} {...baseStroke} {...p}>
<rect x="2" y="5" width="12" height="8" rx="2" />
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
</svg>
),
} satisfies Record<string, (p: Props) => JSX.Element>;
@@ -0,0 +1,36 @@
import { ReactNode, useEffect } from 'react';
interface Props {
onClose: () => void;
children: ReactNode;
className?: string;
}
/**
* Generic modal scrim + panel container. Closes on scrim click and Esc.
* Click events inside the panel are stopped so children can decide their own
* dismiss behaviour.
*/
export const Modal = ({ onClose, children, className }: Props) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="modal-scrim" onClick={onClose} role="dialog" aria-modal="true">
<div
className={className ? `modal ${className}` : 'modal'}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};
@@ -0,0 +1,25 @@
interface Option<T extends string> {
value: T;
label: string;
}
interface Props<T extends string> {
value: T;
options: ReadonlyArray<Option<T>>;
onChange: (value: T) => void;
}
export const SegmentedRadio = <T extends string>({ value, options, onChange }: Props<T>) => (
<div className="srad">
{options.map(o => (
<button
key={o.value}
className={`srad-btn${value === o.value ? ' is-active' : ''}`}
onClick={() => onChange(o.value)}
type="button"
>
{o.label}
</button>
))}
</div>
);
@@ -0,0 +1,27 @@
import { Game } from '../lib/types';
import { deriveState } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
busy: 'Working',
none: '',
};
interface Props {
game: Game;
/** Render even for `none` (used in the detail modal). */
showNone?: boolean;
}
export const StateChip = ({ game, showNone = false }: Props) => {
const state = deriveState(game);
const label = LABELS[state] ?? '';
if (!label && !showNone) return null;
return (
<div className="state-chip" data-state={state}>
<span className="state-dot" />
{label || 'Not downloaded'}
</div>
);
};
@@ -0,0 +1,14 @@
import { Icon } from '../Icon';
interface Props {
title: string;
hint: string;
}
export const EmptyResultsState = ({ title, hint }: Props) => (
<div className="empty-state">
<div className="empty-state-icon"><Icon.games /></div>
<h2 className="empty-state-title">{title}</h2>
<p className="empty-state-hint">{hint}</p>
</div>
);
@@ -0,0 +1,20 @@
import { Icon } from '../Icon';
interface Props {
onChooseDirectory: () => void;
}
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
<div className="empty-state">
<div className="empty-state-icon"><Icon.folder /></div>
<h2 className="empty-state-title">Pick a game directory</h2>
<p className="empty-state-hint">
SoftLAN scans the folder you point it at for installable game bundles
and tracks what your peers on the LAN have available.
</p>
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
<Icon.folder />
<span>Choose folder</span>
</button>
</div>
);
@@ -0,0 +1,68 @@
import { JSX, KeyboardEvent } from 'react';
import { Game } from '../../lib/types';
import { CoverAspect } from '../../hooks/useSettings';
import { formatBytes } from '../../lib/format';
import { GameCover } from './GameCover';
import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Icon } from '../Icon';
interface Props {
game: Game;
aspect: CoverAspect;
thumbnailUrl: string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
}
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
const filtered = parts.filter(Boolean) as string[];
const out: JSX.Element[] = [];
filtered.forEach((p, i) => {
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
out.push(<span key={`p${i}`}>{p}</span>);
});
return out;
};
export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => {
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(game);
}
};
return (
<button
type="button"
className="card"
onClick={() => onOpen(game)}
onKeyDown={onKey}
aria-label={game.name}
>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
<StateChip game={game} />
{game.peer_count > 0 && (
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
<Icon.users />
<span>{game.peer_count}</span>
</div>
)}
</div>
<div className="card-body">
<div className="card-title" title={game.name}>{game.name}</div>
<div className="card-meta">
{metaSeparator(formatBytes(game.size), game.genre || null)}
</div>
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message ?? ''}
</div>
<ActionButton game={game} full onClick={() => onPrimary(game)} />
</div>
</button>
);
};
@@ -0,0 +1,63 @@
import { CSSProperties, useMemo } from 'react';
import { Game } from '../../lib/types';
import { coverColorsFor, titleFontSize } from '../../lib/cover';
import { CoverAspect } from '../../hooks/useSettings';
interface Props {
game: Game;
aspect: CoverAspect;
thumbnailUrl?: string | null;
/** Hide the cover-bottom title overlay (used inside the detail modal hero). */
hideTitle?: boolean;
}
/**
* Cover art. When a real thumbnail is available it's rendered as the
* background image with the same gradient/vignette overlays as the
* placeholder; otherwise the design's procedurally-generated gradient stands
* in. The Bebas Neue title overlay is rendered on top of either.
*/
export const GameCover = ({ game, aspect, thumbnailUrl, hideTitle = false }: Props) => {
const colors = useMemo(() => coverColorsFor(game.id), [game.id]);
const hasThumbnail = Boolean(thumbnailUrl);
// Real cover art already contains its own title; only burn the Bebas Neue
// overlay onto the procedurally-generated placeholder.
const showOverlayTitle = !hideTitle && !hasThumbnail;
const titleStyle: CSSProperties = {
fontSize: titleFontSize(game.name, aspect),
textShadow: `0 4px 16px ${colors.c2}aa, 0 1px 0 rgba(0,0,0,.3)`,
};
return (
<div className="cover">
{hasThumbnail ? (
<img className="cover-image" src={thumbnailUrl!} alt="" loading="lazy" />
) : (
<>
<div
className="cover-base"
style={{
background: `linear-gradient(${colors.angle}deg, ${colors.c1} 0%, ${colors.c2} 100%)`,
}}
/>
<div
className="cover-blob"
style={{
background: `radial-gradient(ellipse at ${colors.blobX}% ${colors.blobY}%, ${colors.accent}38, transparent 55%)`,
}}
/>
</>
)}
<div className="cover-grain" />
{showOverlayTitle && (
<div className="cover-titlewrap">
<div className="cover-title" style={titleStyle}>
{game.name}
</div>
</div>
)}
<div className="cover-vignette" />
</div>
);
};
@@ -0,0 +1,27 @@
import { Game } from '../../lib/types';
import { CoverAspect } from '../../hooks/useSettings';
import { GameCard } from './GameCard';
interface Props {
games: Game[];
aspect: CoverAspect;
getThumbnail: (id: string) => string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
}
export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => (
<div className="grid">
{games.map(g => (
<GameCard
key={g.id}
game={g}
aspect={aspect}
thumbnailUrl={getThumbnail(g.id)}
onOpen={onOpen}
onPrimary={onPrimary}
/>
))}
</div>
);
@@ -0,0 +1,12 @@
interface Props {
shown: number;
total: number;
}
export const ResultsBar = ({ shown, total }: Props) => (
<div className="results-bar">
<div className="results-count">
Showing <strong>{shown}</strong> of {total} games
</div>
</div>
);
@@ -0,0 +1,109 @@
import { Modal } from '../Modal';
import { Icon } from '../Icon';
import { GameCover } from '../grid/GameCover';
import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game } from '../../lib/types';
import { deriveState } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props {
game: Game;
thumbnailUrl: string | null;
onClose: () => void;
onPrimary: (game: Game) => void;
onUninstall: (game: Game) => void;
}
const tagsFromGame = (game: Game): string[] => {
const tags: string[] = [];
if (game.genre) tags.push(game.genre);
if (game.publisher) tags.push(game.publisher);
if (game.release_year) tags.push(game.release_year);
return tags;
};
const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUninstall }: Props) => {
const tags = tagsFromGame(game);
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
<Icon.close />
</button>
<div className="modal-hero">
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
<div className="modal-hero-fade" />
<div className="modal-hero-text">
{tags.length > 0 && (
<div className="modal-tags">
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
</div>
)}
<h2 className="modal-title">{game.name}</h2>
</div>
<div className="modal-state">
<StateChip game={game} showNone />
</div>
</div>
<div className="modal-body">
<div className="modal-meta">
<div className="meta-cell">
<div className="meta-label">Size</div>
<div className="meta-value">{formatBytes(game.size)}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Players</div>
<div className="meta-value">
<Icon.users /> {formatPlayers(game.max_players)}
</div>
</div>
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{statusLabelFor(game)}</div>
</div>
</div>
{game.description && (
<p className="modal-desc">{game.description}</p>
)}
{game.status_message && (
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message}
</p>
)}
<div className="modal-actions">
<ActionButton game={game} size="lg" onClick={() => onPrimary(game)} />
{game.installed && (
<button
type="button"
className="ghost-btn ghost-danger"
onClick={() => onUninstall(game)}
>
<Icon.trash />
<span>Uninstall</span>
</button>
)}
</div>
</div>
</Modal>
);
};
@@ -0,0 +1,92 @@
import { Modal } from '../Modal';
import { Icon } from '../Icon';
import { ColorSwatchPicker } from '../ColorSwatchPicker';
import { SegmentedRadio } from '../SegmentedRadio';
import {
ACCENT_OPTIONS,
ASPECT_OPTIONS,
BG_OPTIONS,
DENSITY_OPTIONS,
UISettings,
} from '../../hooks/useSettings';
interface Props {
settings: UISettings;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
onClose: () => void;
}
interface RowProps {
label: string;
hint: string;
children: React.ReactNode;
}
const Row = ({ label, hint, children }: RowProps) => (
<div className="settings-row">
<div className="settings-row-info">
<div className="settings-row-label">{label}</div>
<div className="settings-row-hint">{hint}</div>
</div>
<div className="settings-row-control">{children}</div>
</div>
);
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
<Modal onClose={onClose} className="settings-modal">
<div className="settings-head">
<h2>Settings</h2>
<button
className="modal-close settings-close"
type="button"
onClick={onClose}
aria-label="Close"
>
<Icon.close />
</button>
</div>
<div className="settings-body">
<section className="settings-section">
<div className="settings-section-title">Appearance</div>
<Row label="Accent color" hint="Used for primary actions and highlights">
<ColorSwatchPicker
value={settings.accent}
options={ACCENT_OPTIONS}
onChange={(v) => onChange('accent', v)}
/>
</Row>
<Row label="Background" hint="Backdrop behind the library">
<SegmentedRadio
value={settings.bg}
options={BG_OPTIONS}
onChange={(v) => onChange('bg', v)}
/>
</Row>
</section>
<section className="settings-section">
<div className="settings-section-title">Library</div>
<Row label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio
value={settings.density}
options={DENSITY_OPTIONS}
onChange={(v) => onChange('density', v)}
/>
</Row>
<Row label="Cover aspect" hint="Shape of the cover art on each card">
<SegmentedRadio
value={settings.aspect}
options={ASPECT_OPTIONS}
onChange={(v) => onChange('aspect', v)}
/>
</Row>
</section>
</div>
<div className="settings-foot">
<button type="button" className="settings-done" onClick={onClose}>Done</button>
</div>
</Modal>
);
@@ -0,0 +1,17 @@
import { Icon } from '../Icon';
import { truncatePath } from '../../lib/format';
interface Props {
path: string;
onClick: () => void;
}
export const DirectoryButton = ({ path, onClick }: Props) => (
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
<Icon.folder />
<span className="dirbtn-label">Game directory</span>
<span className="dirbtn-path">
{path ? truncatePath(path) : 'choose…'}
</span>
</button>
);
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
export type KebabItem =
| { kind: 'item'; label: string; onClick: () => void }
| { kind: 'separator' };
interface Props {
items: ReadonlyArray<KebabItem>;
}
export const KebabMenu = ({ items }: Props) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [open]);
return (
<div className="kebab" ref={ref}>
<button
type="button"
className="kebab-btn"
onClick={() => setOpen(o => !o)}
aria-label="More"
>
<Icon.kebab />
</button>
{open && (
<div className="kebab-menu">
{items.map((it, i) =>
it.kind === 'separator' ? (
<div key={i} className="kebab-sep" />
) : (
<button
key={i}
type="button"
onClick={() => {
setOpen(false);
it.onClick();
}}
>
{it.label}
</button>
),
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import { Icon } from '../Icon';
interface Props {
value: string;
onChange: (value: string) => void;
}
/**
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
* when the user is already typing into another input or textarea.
*/
export const SearchField = ({ value, onChange }: Props) => {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== '/') return;
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<div className="search">
<Icon.search />
<input
ref={inputRef}
type="text"
placeholder="Search games"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<span className="search-kbd">/</span>
</div>
);
};
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react';
import { FilterCounts } from '../../lib/gameState';
import { GameFilter } from '../../lib/types';
interface Tab {
key: GameFilter;
label: string;
}
const TABS: ReadonlyArray<Tab> = [
{ key: 'all', label: 'All Games' },
{ key: 'local', label: 'Local' },
{ key: 'installed', label: 'Installed' },
];
interface Props {
value: GameFilter;
onChange: (value: GameFilter) => void;
counts: FilterCounts;
}
/** Pill-style filter with an animated thumb that slides under the active tab. */
export const SegmentedFilters = ({ value, onChange, counts }: Props) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [thumb, setThumb] = useState({ left: 0, width: 0 });
useEffect(() => {
if (!containerRef.current) return;
const active = containerRef.current.querySelector<HTMLElement>(`[data-key="${value}"]`);
if (active) setThumb({ left: active.offsetLeft, width: active.offsetWidth });
}, [value, counts.all, counts.local, counts.installed]);
return (
<div className="seg" ref={containerRef}>
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width }} />
{TABS.map(t => (
<button
key={t.key}
data-key={t.key}
type="button"
className={`seg-btn${value === t.key ? ' is-active' : ''}`}
onClick={() => onChange(t.key)}
>
<span>{t.label}</span>
<span className="seg-count">{counts[t.key]}</span>
</button>
))}
</div>
);
};
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { GameSort } from '../../lib/types';
const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [
{ key: 'az', label: 'Name (AZ)' },
{ key: 'size', label: 'Size (largest)' },
{ key: 'status', label: 'Status' },
];
interface Props {
value: GameSort;
onChange: (value: GameSort) => void;
}
export const SortMenu = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [open]);
const current = OPTIONS.find(o => o.key === value) ?? OPTIONS[0];
return (
<div className="sort" ref={ref}>
<button className="sort-btn" type="button" onClick={() => setOpen(o => !o)}>
<Icon.sort />
<span>Sort: <strong>{current.label}</strong></span>
<Icon.chevron />
</button>
{open && (
<div className="sort-menu">
{OPTIONS.map(o => (
<button
key={o.key}
type="button"
onClick={() => {
onChange(o.key);
setOpen(false);
}}
>
<span className="sort-check">
{o.key === value && <Icon.check />}
</span>
{o.label}
</button>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,46 @@
import { Brand } from '../Brand';
import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu';
import { DirectoryButton } from './DirectoryButton';
import { KebabMenu, KebabItem } from './KebabMenu';
import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types';
interface Props {
peerCount: number;
filter: GameFilter;
setFilter: (value: GameFilter) => void;
counts: FilterCounts;
query: string;
setQuery: (value: string) => void;
sort: GameSort;
setSort: (value: GameSort) => void;
gameDir: string;
onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>;
}
export const TopBar = ({
peerCount,
filter,
setFilter,
counts,
query,
setQuery,
sort,
setSort,
gameDir,
onPickDirectory,
kebabItems,
}: Props) => (
<header className="topbar">
<Brand peerCount={peerCount} />
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
<SearchField value={query} onChange={setQuery} />
<SortMenu value={sort} onChange={setSort} />
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
<KebabMenu items={kebabItems} />
</header>
);