From 83fbf203839e223cf61640ccea608283acbd0638 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Fri, 14 Nov 2025 20:02:10 +0530 Subject: [PATCH] feat: enhance download page UI with improved layout and styling for cards --- .../src/pages/downloads/download-group.scss | 319 +++++++++---- .../src/pages/downloads/download-group.tsx | 442 +++++++++++------- 2 files changed, 511 insertions(+), 250 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..22bff527 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -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; } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..9f999317 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -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) { - 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>({}); + 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(); + 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

{t("extracting")}

; - } - - if (isGameDeleting(game.id)) { - return

{t("deleting")}

; - } - - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; - } - - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); - } - - return ( - <> -

{progress}

- -

- {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} -

- - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } - - if (download.progress === 1) { - const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - - return download.status === "seeding" && - download.downloader === Downloader.Torrent ? ( - <> -

- {t("seeding")} - - -

- {uploadSpeed &&

{uploadSpeed}/s

} - - ) : ( -

{t("completed")}

- ); - } - - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t(download.queued ? "queued" : "paused")}

- - ); - } - - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

- {formatBytes(download.bytesDownloaded)} / {finalDownloadSize} -

- - ); - } - - return

{t(download.status as string)}

; - }; - 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: , 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: , 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({
    {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 (
  • -
    -
    - {game.title} - -
    - {DOWNLOADER_NAME[game.download!.downloader]} -
    -
    +
    + {game.title} +
    -
    -
    -
    - + +
    +
    +
    + {game.logoImageUrl ? ( + {game.title} + ) : ( +

    + {game.title} +

    + )} +
    + + {DOWNLOADER_NAME[game.download!.downloader]} + +
    +
    +
    +
    +
    +
    +
    + +
    + + NETWORK + + + {isGameDownloading + ? formatSpeed(downloadSpeed) + : "0 B/s"} + +
    +
    +
    + +
    + + PEAK + + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
    +
    +
    + +
    + + size on DISK + + + {finalDownloadSize} + +
    +
    +
    + + {getGameActions(game) !== null && ( + + + + )}
    - {getGameInfo(game)} -
    +
    +
    +
    + + {game.download?.extracting || isGameDeleting(game.id) + ? getStatusText(game) + : formatDownloadProgress(currentProgress)} + + {isGameDownloading && ( + + {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} + {finalDownloadSize} + + )} +
    +
    +
    +
    - {getGameActions(game) !== null && ( - - - - )} +
    + {getStatusText(game)} + {getSeedsPeersText(game) && ( + + • {getSeedsPeersText(game)} + + )} +
    +
    + +
    + {game.download?.progress === 1 ? ( + + ) : isGameDownloading ? ( + + ) : ( + + )} +
    +
    +
    {game.download?.downloader === Downloader.Hydra && (