47e2bbd454
Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.
Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.
Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check
Refs: design reference e308009a08
157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
import { Modal } from '../Modal';
|
|
import { Icon } from '../Icon';
|
|
import { GameCover } from '../grid/GameCover';
|
|
import { StateChip } from '../StateChip';
|
|
import { ActionButton } from '../ActionButton';
|
|
|
|
import { Game, InstallStatus } from '../../lib/types';
|
|
import { deriveState, isInProgress } 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;
|
|
onRemoveDownload: (game: Game) => void;
|
|
onCancelDownload: (game: Game) => void;
|
|
onViewFiles: (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 'downloading': return 'Downloading';
|
|
case 'busy': return 'Working…';
|
|
case 'none': return 'Not downloaded';
|
|
}
|
|
};
|
|
|
|
export const GameDetailModal = ({
|
|
game,
|
|
thumbnailUrl,
|
|
onClose,
|
|
onPrimary,
|
|
onUninstall,
|
|
onRemoveDownload,
|
|
onCancelDownload,
|
|
onViewFiles,
|
|
}: Props) => {
|
|
const tags = tagsFromGame(game);
|
|
const canRemoveDownload = game.downloaded
|
|
&& !game.installed
|
|
&& !isInProgress(game.install_status);
|
|
const canViewFiles = game.downloaded
|
|
|| game.installed
|
|
|| game.install_status === InstallStatus.Downloading;
|
|
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)}
|
|
onCancelDownload={onCancelDownload}
|
|
/>
|
|
{game.installed && (
|
|
<button
|
|
type="button"
|
|
className="ghost-btn ghost-danger"
|
|
onClick={() => onUninstall(game)}
|
|
>
|
|
<Icon.trash />
|
|
<span>Uninstall</span>
|
|
</button>
|
|
)}
|
|
{canRemoveDownload && (
|
|
<button
|
|
type="button"
|
|
className="ghost-btn ghost-danger"
|
|
onClick={() => onRemoveDownload(game)}
|
|
>
|
|
<Icon.trash />
|
|
<span>Remove files</span>
|
|
</button>
|
|
)}
|
|
{canViewFiles && (
|
|
<>
|
|
<div className="modal-actions-spacer" />
|
|
<button
|
|
type="button"
|
|
className="ghost-btn"
|
|
onClick={() => onViewFiles(game)}
|
|
>
|
|
<Icon.folder />
|
|
<span>View Files</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|