f62515451b
NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be clear to users. Keep streamed installs in the installed visual state so sorting, filters, and the primary Play action stay unchanged, but make the sharing limitation visible in the UI. Cards now label that state as `Not shareable`, while the detail modal status says `Installed, not shareable`. Downloaded-and-installed games keep the normal `Installed` wording. Test Plan: - just frontend-test - just build - git diff --check - git diff --cached --check Refs: NEXT_STEPS.md item 7
188 lines
7.6 KiB
TypeScript
188 lines
7.6 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 { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
|
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
|
|
|
interface Props {
|
|
game: Game;
|
|
thumbnailUrl: string | null;
|
|
onClose: () => void;
|
|
onPrimary: (game: Game) => void;
|
|
onStreamInstall: (game: Game) => void;
|
|
onUninstall: (game: Game) => void;
|
|
onRemoveDownload: (game: Game) => void;
|
|
onCancelDownload: (game: Game) => void;
|
|
onStartServer: (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;
|
|
};
|
|
|
|
export const GameDetailModal = ({
|
|
game,
|
|
thumbnailUrl,
|
|
onClose,
|
|
onPrimary,
|
|
onStreamInstall,
|
|
onUninstall,
|
|
onRemoveDownload,
|
|
onCancelDownload,
|
|
onStartServer,
|
|
onViewFiles,
|
|
}: Props) => {
|
|
const tags = tagsFromGame(game);
|
|
// Some game metadata contains a literal <br>; keep sanitization exact.
|
|
const description = game.description.split('<br>').join('');
|
|
const canRemoveDownload = game.downloaded
|
|
&& !game.installed
|
|
&& !isInProgress(game.install_status);
|
|
const showStreamInstall = canStreamInstall(game);
|
|
const canViewFiles = game.downloaded
|
|
|| game.installed
|
|
|| game.install_status === InstallStatus.Downloading
|
|
|| game.install_status === InstallStatus.Installing;
|
|
const newerThanExpected = hasNewerLocalVersion(game);
|
|
const newerStatus = newerThanExpected
|
|
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
|
: undefined;
|
|
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
|
const outboundStatus = hasOutbound
|
|
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
|
: undefined;
|
|
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
|
const statusLevel = hasOutbound
|
|
? 'info'
|
|
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
|
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.eti_game_version ?? game.local_version)}
|
|
</div>
|
|
</div>
|
|
<div className="meta-cell">
|
|
<div className="meta-label">Status</div>
|
|
<div className="meta-value">{gameStatusLabel(game)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{description && (
|
|
<p className="modal-desc">{description}</p>
|
|
)}
|
|
|
|
{statusMessage && (
|
|
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
|
{statusMessage}
|
|
</p>
|
|
)}
|
|
|
|
<div className="modal-actions">
|
|
<ActionButton
|
|
game={game}
|
|
size="lg"
|
|
onClick={() => onPrimary(game)}
|
|
onCancelDownload={onCancelDownload}
|
|
/>
|
|
{showStreamInstall && (
|
|
<button
|
|
type="button"
|
|
className="ghost-btn"
|
|
title="Install without keeping archive files"
|
|
onClick={() => onStreamInstall(game)}
|
|
>
|
|
<Icon.install />
|
|
<span>Low disk install</span>
|
|
</button>
|
|
)}
|
|
{game.installed && game.can_host_server === true && (
|
|
<button
|
|
type="button"
|
|
className="act-btn act-lg act-server"
|
|
onClick={() => onStartServer(game)}
|
|
>
|
|
<Icon.server />
|
|
<span className="act-label">Start Server</span>
|
|
</button>
|
|
)}
|
|
{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>
|
|
);
|
|
};
|