Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
214 lines
6.6 KiB
TypeScript
214 lines
6.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,
|
|
gameTransferStatusPresentation,
|
|
hasNewerLocalVersion,
|
|
isInProgress,
|
|
} from "../../lib/gameState";
|
|
import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format";
|
|
|
|
interface Props {
|
|
game: Game;
|
|
thumbnailUrl: string | null;
|
|
supportsStreamedInstall: boolean;
|
|
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,
|
|
supportsStreamedInstall,
|
|
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, supportsStreamedInstall);
|
|
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 transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
|
const statusMessage = transferStatus?.message ?? outboundStatus ??
|
|
game.status_message ?? newerStatus;
|
|
const statusLevel = transferStatus?.level ??
|
|
(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>
|
|
);
|
|
};
|