feat: enhance download page UI with improved layout and styling for cards

This commit is contained in:
ctrlcat0x
2025-11-14 20:02:10 +05:30
parent c600a4a46f
commit 83fbf20383
2 changed files with 511 additions and 250 deletions

View File

@@ -5,14 +5,6 @@
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__details-with-article {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start;
cursor: pointer;
}
&__header {
display: flex;
align-items: center;
@@ -30,29 +22,9 @@
}
}
&__title-wrapper {
display: flex;
align-items: center;
margin-bottom: globals.$spacing-unit;
gap: globals.$spacing-unit;
}
&__title {
font-weight: bold;
cursor: pointer;
color: globals.$body-color;
text-align: left;
font-size: 16px;
display: block;
&:hover {
text-decoration: underline;
}
}
&__downloads {
width: 100%;
gap: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 3);
display: flex;
flex-direction: column;
margin: 0;
@@ -67,86 +39,259 @@
border-radius: 8px;
border: solid 1px globals.$border-color;
overflow: hidden;
box-shadow: 0px 0px 5px 0px #000000;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.5);
transition: all ease 0.2s;
height: 140px;
min-height: 140px;
max-height: 140px;
height: 250px;
min-height: 250px;
max-height: 250px;
position: relative;
&:before {
content: "";
top: 0;
left: 0;
width: 100%;
height: 172%;
position: absolute;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 64%,
rgba(255, 255, 255, 0.1) 100%
);
transition: all ease 0.3s;
transform: translateY(-36%);
opacity: 0.5;
z-index: 1;
}
&:hover {
transform: scale(1.01);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.1);
}
&:hover::before {
opacity: 1;
transform: translateY(-20%);
}
&--hydra {
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
}
}
&__cover {
width: 280px;
min-width: 280px;
height: auto;
border-right: solid 1px globals.$border-color;
&__background-image {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 25%;
}
}
&__background-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
130deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.5) 50%,
rgba(0, 0, 0, 0.8) 100%
);
}
&__content {
position: relative;
z-index: 1;
&-content {
width: 100%;
height: 100%;
padding: globals.$spacing-unit;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
&-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.8) 5%,
transparent 100%
);
display: flex;
overflow: hidden;
z-index: 1;
}
&-image {
width: 100%;
height: 100%;
position: absolute;
z-index: -1;
}
}
&__right-content {
z-index: 2;
width: 100%;
height: 100%;
display: flex;
padding: calc(globals.$spacing-unit * 2);
flex: 1;
gap: globals.$spacing-unit;
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
}
&__details {
&__left-section {
flex: 1;
max-width: 50%;
height: 100%;
display: flex;
align-items: flex-end;
padding: calc(globals.$spacing-unit * 2);
}
&__logo-container {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
gap: globals.$spacing-unit;
}
&__actions {
&__logo {
max-width: 350px;
max-height: 150px;
object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8));
}
&__game-title {
font-size: 24px;
font-weight: 700;
color: #ffffff;
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9);
margin: 0;
}
&__downloader-badge {
align-self: flex-start;
}
&__right-section {
flex: 1;
max-width: 50%;
display: flex;
flex-direction: column;
padding: calc(globals.$spacing-unit * 2);
position: relative;
justify-content: space-between;
}
&__top-row {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2);
}
&__stats {
display: flex;
gap: calc(globals.$spacing-unit * 3);
}
&__stat {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
svg {
opacity: 0.8;
flex-shrink: 0;
}
}
&__stat-info {
display: flex;
flex-direction: column;
gap: 2px;
}
&__stat-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
line-height: 1;
}
&__stat-value {
color: #ffffff;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
&__menu-button {
position: absolute;
top: 12px;
right: 12px;
border-radius: 50%;
border: none;
padding: 8px;
min-height: unset;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
flex-shrink: 0;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
}
&__progress-section {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
flex: 1;
}
&__bottom-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
&__progress-text {
font-weight: 600;
}
&__progress-size {
color: globals.$muted-color;
}
&__progress-bar {
width: 100%;
height: 6px;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
position: relative;
}
&__progress-fill {
height: 100%;
background-color: globals.$muted-color;
transition: width 0.3s ease;
border-radius: 4px;
}
&__time-remaining {
font-size: 11px;
color: globals.$muted-color;
text-align: left;
min-height: 16px;
}
&__quick-actions {
display: flex;
flex-shrink: 0;
min-height: 40px;
align-items: center;
}
&__action-btn {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
font-size: 13px;
font-weight: 600;
svg {
width: 14px;
height: 14px;
}
}
&__hydra-gradient {
@@ -156,6 +301,6 @@
position: absolute;
bottom: 0;
height: 2px;
z-index: 1;
z-index: 2;
}
}

View File

@@ -1,21 +1,18 @@
import { useNavigate } from "react-router-dom";
import cn from "classnames";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { formatDownloadProgress } from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { Downloader, formatBytes, formatBytesToMbps } from "@shared";
import { formatDistance, addMilliseconds } from "date-fns";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import "./download-group.scss";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
DropdownMenu,
DropdownMenuItem,
@@ -26,11 +23,12 @@ import {
FileDirectoryIcon,
LinkIcon,
PlayIcon,
QuestionIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
XCircleIcon,
DatabaseIcon,
GraphIcon,
} from "@primer/octicons-react";
export interface DownloadGroupProps {
@@ -48,8 +46,6 @@ export function DownloadGroup({
openGameInstaller,
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
@@ -60,7 +56,6 @@ export function DownloadGroup({
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
cancelDownload,
@@ -69,11 +64,26 @@ export function DownloadGroup({
resumeSeeding,
} = useDownload();
const peakSpeedsRef = useRef<Record<string, number>>({});
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed) {
const currentPeak = peakSpeedsRef.current[lastPacket.gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
peakSpeedsRef.current[lastPacket.gameId] = lastPacket.downloadSpeed;
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed]);
const isGameSeeding = (game: LibraryGame) => {
const entry = seedingStatus.find((s) => s.gameId === game.id);
if (entry && entry.status) return entry.status === "seeding";
return game.download?.status === "seeding";
};
const getFinalDownloadSize = (game: LibraryGame) => {
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
if (download.fileSize) return formatBytes(download.fileSize);
if (download.fileSize != null) return formatBytes(download.fileSize);
if (lastPacket?.download.fileSize && isGameDownloading)
return formatBytes(lastPacket.download.fileSize);
@@ -81,15 +91,100 @@ export function DownloadGroup({
return "N/A";
};
const seedingMap = useMemo(() => {
const map = new Map<string, SeedingStatus>();
const formatSpeed = (speed: number): string => {
return userPreferences?.showDownloadSpeedInMegabytes
? `${formatBytes(speed)}/s`
: formatBytesToMbps(speed);
};
seedingStatus.forEach((seed) => {
map.set(seed.gameId, seed);
});
const calculateETA = () => {
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
return map;
}, [seedingStatus]);
try {
return formatDistance(
addMilliseconds(new Date(), lastPacket.timeRemaining),
new Date(),
{ addSuffix: true }
);
} catch (err) {
return "";
}
};
const getStatusText = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.gameId === game.id;
const status = game.download?.status;
if (game.download?.extracting) {
return t("extracting");
}
if (isGameDeleting(game.id)) {
return t("deleting");
}
if (game.download?.progress === 1) {
const isTorrent = game.download?.downloader === Downloader.Torrent;
if (isTorrent) {
if (isGameSeeding(game)) {
return `${t("completed")} (${t("seeding")})`;
}
return `${t("completed")} (${t("paused")})`;
}
return t("completed");
}
if (isGameDownloading) {
if (lastPacket.isDownloadingMetadata) {
return t("downloading_metadata");
}
if (lastPacket.isCheckingFiles) {
return t("checking_files");
}
if (lastPacket.timeRemaining && lastPacket.timeRemaining > 0) {
return calculateETA();
}
return t("calculating_eta");
}
if (status === "paused") {
return t("paused");
}
if (status === "waiting") {
return t("calculating_eta");
}
if (status === "error") {
return t("paused");
}
return t("paused");
};
const getSeedsPeersText = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.gameId === game.id;
const isTorrent = game.download?.downloader === Downloader.Torrent;
if (!isTorrent) return null;
if (game.download?.progress === 1 && isGameSeeding(game)) {
if (
isGameDownloading &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0)
) {
return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`;
}
return null;
}
if (
isGameDownloading &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0)
) {
return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`;
}
return null;
};
const extractGameDownload = useCallback(
async (shop: GameShop, objectId: string) => {
@@ -99,102 +194,6 @@ export function DownloadGroup({
[updateLibrary]
);
const getGameInfo = (game: LibraryGame) => {
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
if (download.extracting) {
return <p>{t("extracting")}</p>;
}
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (lastPacket?.isCheckingFiles) {
return (
<>
<p>{progress}</p>
<p>{t("checking_files")}</p>
</>
);
}
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{download.downloader === Downloader.Torrent && (
<small
className="download-group__details-with-article"
data-open-article="peers-and-seeds"
>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
<QuestionIcon size={12} />
</small>
)}
</>
);
}
if (download.progress === 1) {
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
return download.status === "seeding" &&
download.downloader === Downloader.Torrent ? (
<>
<p
data-open-article="seeding"
className="download-group__details-with-article"
>
{t("seeding")}
<QuestionIcon />
</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
</>
) : (
<p>{t("completed")}</p>
);
}
if (download.status === "paused") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>{t(download.queued ? "queued" : "paused")}</p>
</>
);
}
if (download.status === "active") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>
{formatBytes(download.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(download.status as string)}</p>;
};
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download;
const isGameDownloading = lastPacket?.gameId === game.id;
@@ -202,7 +201,7 @@ export function DownloadGroup({
const deleting = isGameDeleting(game.id);
if (game.download?.progress === 1) {
return [
const actions = [
{
label: t("install"),
disabled: deleting,
@@ -224,7 +223,7 @@ export function DownloadGroup({
disabled: deleting,
icon: <UnlinkIcon />,
show:
game.download?.status === "seeding" &&
isGameSeeding(game) &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
pauseSeeding(game.shop, game.objectId);
@@ -235,7 +234,7 @@ export function DownloadGroup({
disabled: deleting,
icon: <LinkIcon />,
show:
game.download?.status !== "seeding" &&
!isGameSeeding(game) &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
resumeSeeding(game.shop, game.objectId);
@@ -250,6 +249,7 @@ export function DownloadGroup({
},
},
];
return actions.filter((action) => action.show !== false);
}
if (isGameDownloading) {
@@ -308,6 +308,17 @@ export function DownloadGroup({
<ul className="download-group__downloads">
{library.map((game) => {
const isGameDownloading = lastPacket?.gameId === game.id;
const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeedsRef.current[game.id] || 0;
const currentProgress = isGameDownloading
? lastPacket.progress
: game.download?.progress || 0;
return (
<li
key={game.id}
@@ -316,55 +327,160 @@ export function DownloadGroup({
game.download?.downloader === Downloader.Hydra,
})}
>
<div className="download-group__cover">
<div className="download-group__cover-backdrop">
<img
src={game.libraryImageUrl ?? ""}
className="download-group__cover-image"
alt={game.title}
/>
<div className="download-group__cover-content">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
</div>
<div className="download-group__background-image">
<img
src={game.libraryHeroImageUrl || game.libraryImageUrl || ""}
alt={game.title}
/>
<div className="download-group__background-overlay" />
</div>
<div className="download-group__right-content">
<div className="download-group__details">
<div className="download-group__title-wrapper">
<button
type="button"
className="download-group__title"
onClick={() =>
navigate(
buildGameDetailsPath({
...game,
objectId: game.objectId,
})
)
}
>
{game.title}
</button>
<div className="download-group__content">
<div className="download-group__left-section">
<div className="download-group__logo-container">
{game.logoImageUrl ? (
<img
src={game.logoImageUrl}
alt={game.title}
className="download-group__logo"
/>
) : (
<h3 className="download-group__game-title">
{game.title}
</h3>
)}
<div className="download-group__downloader-badge">
<Badge>
{DOWNLOADER_NAME[game.download!.downloader]}
</Badge>
</div>
</div>
</div>
<div className="download-group__right-section">
<div className="download-group__top-row">
<div className="download-group__stats">
<div className="download-group__stat">
<DownloadIcon size={16} />
<div className="download-group__stat-info">
<span className="download-group__stat-label">
NETWORK
</span>
<span className="download-group__stat-value">
{isGameDownloading
? formatSpeed(downloadSpeed)
: "0 B/s"}
</span>
</div>
</div>
<div className="download-group__stat">
<GraphIcon size={16} />
<div className="download-group__stat-info">
<span className="download-group__stat-label">
PEAK
</span>
<span className="download-group__stat-value">
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
</span>
</div>
</div>
<div className="download-group__stat">
<DatabaseIcon size={16} />
<div className="download-group__stat-info">
<span className="download-group__stat-label">
size on DISK
</span>
<span className="download-group__stat-value">
{finalDownloadSize}
</span>
</div>
</div>
</div>
{getGameActions(game) !== null && (
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
className="download-group__menu-button"
theme="outline"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
)}
</div>
{getGameInfo(game)}
</div>
<div className="download-group__bottom-row">
<div className="download-group__progress-section">
<div className="download-group__progress-info">
<span className="download-group__progress-text">
{game.download?.extracting || isGameDeleting(game.id)
? getStatusText(game)
: formatDownloadProgress(currentProgress)}
</span>
{isGameDownloading && (
<span className="download-group__progress-size">
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
{finalDownloadSize}
</span>
)}
</div>
<div className="download-group__progress-bar">
<div
className="download-group__progress-fill"
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
{getGameActions(game) !== null && (
<DropdownMenu
align="end"
items={getGameActions(game)}
sideOffset={-75}
>
<Button
className="download-group__menu-button"
theme="outline"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
)}
<div className="download-group__time-remaining">
{getStatusText(game)}
{getSeedsPeersText(game) && (
<span style={{ opacity: 0.7, marginLeft: "8px" }}>
{getSeedsPeersText(game)}
</span>
)}
</div>
</div>
<div className="download-group__quick-actions">
{game.download?.progress === 1 ? (
<Button
theme="primary"
onClick={() =>
openGameInstaller(game.shop, game.objectId)
}
className="download-group__action-btn"
disabled={isGameDeleting(game.id)}
>
<DownloadIcon size={16} />
{t("install")}
</Button>
) : isGameDownloading ? (
<Button
theme="primary"
onClick={() =>
pauseDownload(game.shop, game.objectId)
}
className="download-group__action-btn"
>
<ColumnsIcon size={16} />
{t("pause")}
</Button>
) : (
<Button
theme="primary"
onClick={() =>
resumeDownload(game.shop, game.objectId)
}
className="download-group__action-btn"
>
<PlayIcon size={16} />
{t("resume")}
</Button>
)}
</div>
</div>
</div>
</div>
{game.download?.downloader === Downloader.Hydra && (