feat(ui): add download progress controls
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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { CSSProperties, JSX, MouseEvent } from 'react';
|
||||
import { JSX, MouseEvent } from 'react';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { DownloadProgress } from './DownloadProgress';
|
||||
import { Game, InstallStatus } from '../lib/types';
|
||||
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface Props {
|
||||
size?: 'md' | 'lg';
|
||||
full?: boolean;
|
||||
onClick: () => void;
|
||||
onCancelDownload?: (game: Game) => void;
|
||||
}
|
||||
|
||||
const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
|
||||
@@ -18,29 +20,34 @@ const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
|
||||
download: <Icon.download />,
|
||||
};
|
||||
|
||||
const downloadProgressPercent = (game: Game): number | undefined => {
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return undefined;
|
||||
|
||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
||||
};
|
||||
|
||||
/** Color-coded primary action: Play / Install / Update / Download / busy. */
|
||||
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
|
||||
export const ActionButton = ({
|
||||
game,
|
||||
size = 'md',
|
||||
full = false,
|
||||
onClick,
|
||||
onCancelDownload,
|
||||
}: Props) => {
|
||||
const action = primaryActionFor(game);
|
||||
const isDownloading = game.install_status === InstallStatus.Downloading;
|
||||
const progressPercent = downloadProgressPercent(game);
|
||||
if (isDownloading) {
|
||||
return (
|
||||
<DownloadProgress
|
||||
game={game}
|
||||
size={size}
|
||||
full={full}
|
||||
onCancel={size === 'lg' ? onCancelDownload : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = [
|
||||
'act-btn',
|
||||
`act-${action}`,
|
||||
isDownloading ? 'act-downloading' : '',
|
||||
size === 'lg' ? 'act-lg' : '',
|
||||
full ? 'act-full' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const disabled = action === 'busy' || action === 'disabled';
|
||||
const style = progressPercent === undefined
|
||||
? undefined
|
||||
: ({ '--download-progress': `${progressPercent}%` } as CSSProperties);
|
||||
|
||||
const handle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -49,8 +56,7 @@ export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={cls} onClick={handle} disabled={disabled} style={style}>
|
||||
{isDownloading && <span className="act-progress-fill" aria-hidden />}
|
||||
<button className={cls} onClick={handle} disabled={disabled}>
|
||||
{ICON_FOR_ACTION[action]}
|
||||
<span className="act-label">{actionLabel(game)}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { CSSProperties, MouseEvent } from 'react';
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import {
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
} from '../lib/gameState';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
size?: 'md' | 'lg';
|
||||
full?: boolean;
|
||||
onCancel?: (game: Game) => void;
|
||||
}
|
||||
|
||||
const progressStats = (game: Game) => {
|
||||
const progress = game.download_progress;
|
||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||
const total = progress?.total_bytes ?? game.size;
|
||||
const speed = progress?.bytes_per_second ?? 0;
|
||||
const remaining = Math.max(0, total - downloaded);
|
||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||
|
||||
return {
|
||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
|
||||
const stats = progressStats(game);
|
||||
const progressStyle = {
|
||||
'--download-progress': `${stats.pct}%`,
|
||||
} as CSSProperties;
|
||||
const className = [
|
||||
'dl',
|
||||
size === 'lg' ? 'dl-lg' : 'dl-md',
|
||||
full ? 'dl-full' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCancel?.(game);
|
||||
};
|
||||
|
||||
if (size === 'lg') {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-lg-grid">
|
||||
<div className="dl-lg-primary">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span className="dl-label">Downloading</span>
|
||||
</div>
|
||||
<div className="dl-lg-secondary">
|
||||
<span className="dl-bytes">
|
||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
<div className="dl-lg-pct">
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="dl-cancel"
|
||||
onClick={handleCancel}
|
||||
aria-label={`Cancel download of ${game.name}`}
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-md-row">
|
||||
<span className="dl-pct">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</span>
|
||||
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { deriveState } from '../lib/gameState';
|
||||
const LABELS: Record<string, string> = {
|
||||
installed: 'Installed',
|
||||
local: 'Local',
|
||||
downloading: 'Downloading',
|
||||
busy: 'Working',
|
||||
none: '',
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
}
|
||||
|
||||
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
||||
@@ -27,7 +28,14 @@ const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[
|
||||
return out;
|
||||
};
|
||||
|
||||
export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => {
|
||||
export const GameCard = ({
|
||||
game,
|
||||
aspect,
|
||||
thumbnailUrl,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
}: Props) => {
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -61,7 +69,12 @@ export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Prop
|
||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message ?? ''}
|
||||
</div>
|
||||
<ActionButton game={game} full onClick={() => onPrimary(game)} />
|
||||
<ActionButton
|
||||
game={game}
|
||||
full
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,17 @@ interface Props {
|
||||
getThumbnail: (id: string) => string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
}
|
||||
|
||||
export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => (
|
||||
export const GameGrid = ({
|
||||
games,
|
||||
aspect,
|
||||
getThumbnail,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
}: Props) => (
|
||||
<div className="grid">
|
||||
{games.map(g => (
|
||||
<GameCard
|
||||
@@ -21,6 +29,7 @@ export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Pro
|
||||
thumbnailUrl={getThumbnail(g.id)}
|
||||
onOpen={onOpen}
|
||||
onPrimary={onPrimary}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GameCover } from '../grid/GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { deriveState, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
@@ -15,6 +15,8 @@ interface Props {
|
||||
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[] => {
|
||||
@@ -29,6 +31,7 @@ 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';
|
||||
}
|
||||
@@ -41,11 +44,16 @@ export const GameDetailModal = ({
|
||||
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">
|
||||
@@ -102,7 +110,12 @@ export const GameDetailModal = ({
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton game={game} size="lg" onClick={() => onPrimary(game)} />
|
||||
<ActionButton
|
||||
game={game}
|
||||
size="lg"
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -123,6 +136,19 @@ export const GameDetailModal = ({
|
||||
<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>
|
||||
|
||||
@@ -9,13 +9,15 @@ export interface GameActions {
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => Promise<void>;
|
||||
viewFiles: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot.
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
@@ -65,5 +67,21 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload };
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('cancel_download', { id });
|
||||
} catch (err) {
|
||||
console.error('cancel_download failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const viewFiles = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('open_game_files', { id });
|
||||
} catch (err) {
|
||||
console.error('open_game_files failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export const mergeGameUpdate = (
|
||||
|
||||
/** Visual card state — used for state chip color and action button styling. */
|
||||
export const deriveState = (game: Game): DerivedState => {
|
||||
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (game.installed) return 'installed';
|
||||
if (game.downloaded) return 'local';
|
||||
@@ -126,6 +127,48 @@ export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const DECIMAL_MB = 1_000_000;
|
||||
|
||||
const stripTrailingDecimalZeros = (value: string): string =>
|
||||
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
||||
|
||||
export const downloadProgressPercent = (game: Game): number => {
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
||||
};
|
||||
|
||||
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return `${Math.round(mb)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const safeBytes = Math.max(0, bytes);
|
||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||
|
||||
const gb = safeBytes / GB;
|
||||
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
||||
};
|
||||
|
||||
export const formatDownloadEta = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
};
|
||||
|
||||
export const inProgressLabel = (game: Game): string | undefined => {
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
@@ -161,19 +204,22 @@ export interface FilterCounts {
|
||||
installed: number;
|
||||
}
|
||||
|
||||
const isDownloading = (game: Game): boolean =>
|
||||
game.install_status === InstallStatus.Downloading;
|
||||
|
||||
const isNetworkGame = (game: Game): boolean =>
|
||||
game.installed || game.downloaded || game.peer_count > 0;
|
||||
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
||||
|
||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||
all: games.filter(isNetworkGame).length,
|
||||
local: games.filter(g => g.installed || g.downloaded).length,
|
||||
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
||||
installed: games.filter(g => g.installed).length,
|
||||
});
|
||||
|
||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
return game.installed || game.downloaded;
|
||||
return game.installed || game.downloaded || isDownloading(game);
|
||||
case 'installed':
|
||||
return game.installed;
|
||||
case 'all':
|
||||
@@ -182,10 +228,11 @@ const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
};
|
||||
|
||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||
busy: 0,
|
||||
installed: 1,
|
||||
local: 2,
|
||||
none: 3,
|
||||
installed: 0,
|
||||
local: 1,
|
||||
downloading: 2,
|
||||
busy: 3,
|
||||
none: 4,
|
||||
};
|
||||
|
||||
const compareByState = (a: Game, b: Game): number => {
|
||||
|
||||
@@ -75,5 +75,5 @@ export type GameFilter = 'all' | 'local' | 'installed';
|
||||
/** Library sort order. */
|
||||
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from install/download flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'none' | 'busy';
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
|
||||
@@ -629,6 +629,11 @@
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 8px var(--warn);
|
||||
}
|
||||
.state-chip[data-state="downloading"] .state-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
animation: state-busy 1.2s ease-in-out infinite;
|
||||
}
|
||||
.state-chip[data-state="busy"] .state-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
@@ -807,20 +812,6 @@
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--bd-1);
|
||||
}
|
||||
.act-downloading {
|
||||
min-width: 148px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.act-lg.act-downloading {
|
||||
min-width: 174px;
|
||||
}
|
||||
.act-progress-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--download-progress, 0%);
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.45s linear;
|
||||
}
|
||||
.act-busy::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
@@ -846,6 +837,264 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Download progress */
|
||||
.dl {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
container-type: inline-size;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.dl-full {
|
||||
width: 100%;
|
||||
}
|
||||
.dl-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 0;
|
||||
width: var(--download-progress, 0%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 26%, transparent) 100%
|
||||
);
|
||||
border-right: 1px solid color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
box-shadow: 2px 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
transition: width 0.48s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.dl-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
115deg,
|
||||
transparent 0 14px,
|
||||
rgba(255, 255, 255, 0.05) 14px 22px
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: dl-stripe 1.4s linear infinite;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes dl-stripe {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -36px 0;
|
||||
}
|
||||
}
|
||||
.dl-pulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
animation: dl-pulse 1.4s ease-out infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@keyframes dl-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
.dl-md {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.dl-md-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.dl-md .dl-pct {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dl-md .dl-pulse {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.dl-pct-sym {
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.dl-md .dl-speed {
|
||||
color: var(--t-2);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@container (max-width: 132px) {
|
||||
.dl-md .dl-speed {
|
||||
display: none;
|
||||
}
|
||||
.dl-md-row {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@container (max-width: 96px) {
|
||||
.dl-md .dl-pulse {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.density-compact .dl-md {
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
}
|
||||
.density-compact .dl-md-row {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.density-compact .dl-md .dl-speed {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.density-large .dl-md {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.density-large .dl-md-row {
|
||||
font-size: 13px;
|
||||
}
|
||||
.density-large .dl-md .dl-speed {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.dl-lg {
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 260px;
|
||||
}
|
||||
.dl-lg-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"primary pct cancel"
|
||||
"secondary pct cancel";
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 14px 0 16px;
|
||||
column-gap: 14px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.dl-lg-primary {
|
||||
grid-area: primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--accent) 80%, white);
|
||||
}
|
||||
.dl-lg-primary .dl-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary {
|
||||
grid-area: secondary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--t-2);
|
||||
}
|
||||
.dl-lg-secondary .dl-bytes {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-of {
|
||||
color: var(--t-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.dl-lg-secondary .dl-speed {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-eta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@container (max-width: 380px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.dl-lg-pct {
|
||||
grid-area: pct;
|
||||
color: var(--t-1);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.dl-lg-pct .dl-pct-sym {
|
||||
font-size: 12px;
|
||||
}
|
||||
.dl-cancel {
|
||||
grid-area: cancel;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bd-2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--t-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.dl-cancel:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Ghost / secondary buttons */
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -152,6 +152,7 @@ export const MainWindow = () => {
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -170,6 +171,8 @@ export const MainWindow = () => {
|
||||
onPrimary={handlePrimary}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user