mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
style: enhance download group styling for improved layout, responsiveness, and user interaction
This commit is contained in:
@@ -18,19 +18,26 @@
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
&-divider {
|
||||
&-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
background-color: globals.$border-color;
|
||||
height: 1px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 6px 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -91,11 +98,25 @@
|
||||
&__hero-logo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
max-width: 180px;
|
||||
max-height: 60px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
max-width: 220px;
|
||||
@@ -124,6 +145,18 @@
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
font-size: 26px;
|
||||
@@ -217,15 +250,34 @@
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
align-self: flex-end;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
@@ -312,14 +364,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__simple-thumbnail {
|
||||
@@ -329,6 +374,7 @@
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid globals.$border-color;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -356,6 +402,12 @@
|
||||
}
|
||||
|
||||
&__simple-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__simple-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
@@ -364,9 +416,20 @@
|
||||
}
|
||||
|
||||
&__simple-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__simple-extracting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-seeding {
|
||||
color: #4ade80;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import {
|
||||
formatDownloadProgress,
|
||||
buildGameDetailsPath,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import { Downloader, formatBytes, formatBytesToMbps } from "@shared";
|
||||
import { addMilliseconds } from "date-fns";
|
||||
@@ -16,11 +19,14 @@ import {
|
||||
import "./download-group.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
} from "@renderer/components/dropdown-menu/dropdown-menu";
|
||||
import {
|
||||
ClockIcon,
|
||||
ColumnsIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
@@ -34,6 +40,47 @@ import {
|
||||
} from "@primer/octicons-react";
|
||||
import { average } from "color.js";
|
||||
|
||||
interface AnimatedPercentageProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
function AnimatedPercentage({ value }: Readonly<AnimatedPercentageProps>) {
|
||||
const percentageText = formatDownloadProgress(value);
|
||||
const prevTextRef = useRef<string>(percentageText);
|
||||
const chars = percentageText.split("");
|
||||
const prevChars = prevTextRef.current.split("");
|
||||
|
||||
useEffect(() => {
|
||||
prevTextRef.current = percentageText;
|
||||
}, [percentageText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, index) => {
|
||||
const prevChar = prevChars[index];
|
||||
const charChanged = prevChar !== char;
|
||||
|
||||
return (
|
||||
<AnimatePresence key={`${index}`} mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`${char}-${value}-${index}`}
|
||||
initial={
|
||||
charChanged ? { y: 10, opacity: 0 } : { y: 0, opacity: 1 }
|
||||
}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={charChanged ? { y: -10, opacity: 0 } : undefined}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
style={{ display: "inline-block" }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpeedChartProps {
|
||||
speeds: number[];
|
||||
peakSpeed: number;
|
||||
@@ -55,6 +102,7 @@ function SpeedChart({
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const draw = () => {
|
||||
const clientWidth = canvas.clientWidth;
|
||||
@@ -66,10 +114,12 @@ function SpeedChart({
|
||||
|
||||
const width = clientWidth;
|
||||
const height = 100;
|
||||
const totalBars = 120;
|
||||
const barWidth = 4;
|
||||
const barGap = 10;
|
||||
const barSpacing = barWidth + barGap;
|
||||
|
||||
// Calculate how many bars can fit in the available width
|
||||
const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing));
|
||||
const maxHeight = peakSpeed || Math.max(...speeds, 1);
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
@@ -126,8 +176,22 @@ function SpeedChart({
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
|
||||
// Handle resize - trigger redraw when canvas size changes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// Cancel any pending animation frame to force immediate redraw
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
// Trigger a redraw that will recalculate bars based on new width
|
||||
draw();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [speeds, peakSpeed, color]);
|
||||
|
||||
@@ -173,6 +237,12 @@ function HeroDownloadView({
|
||||
cancelDownload,
|
||||
t,
|
||||
}: Readonly<HeroDownloadViewProps>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate(buildGameDetailsPath(game));
|
||||
}, [navigate, game]);
|
||||
|
||||
return (
|
||||
<div className="download-group download-group--hero">
|
||||
<div className="download-group__hero-background">
|
||||
@@ -187,9 +257,33 @@ function HeroDownloadView({
|
||||
<div className="download-group__hero-action-row">
|
||||
<div className="download-group__hero-logo">
|
||||
{game.logoImageUrl ? (
|
||||
<img src={game.logoImageUrl} alt={game.title} />
|
||||
<img
|
||||
src={game.logoImageUrl}
|
||||
alt={game.title}
|
||||
onClick={handleLogoClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleLogoClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
) : (
|
||||
<h1>{game.title}</h1>
|
||||
<h1
|
||||
onClick={handleLogoClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleLogoClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{game.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,23 +292,35 @@ function HeroDownloadView({
|
||||
<div className="download-group__progress-row download-group__progress-row--bar">
|
||||
<div className="download-group__progress-wrapper">
|
||||
<div className="download-group__progress-info-row">
|
||||
{lastPacket?.isCheckingFiles ? (
|
||||
<span className="download-group__progress-status">
|
||||
{t("checking_files")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__progress-size">
|
||||
<DownloadIcon size={14} />
|
||||
{isGameDownloading && lastPacket
|
||||
? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}`
|
||||
: `0 B / ${finalDownloadSize}`}
|
||||
</span>
|
||||
)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="download-group__progress-info-row">
|
||||
{!lastPacket?.isCheckingFiles && (
|
||||
<span className="download-group__progress-time">
|
||||
{isGameDownloading &&
|
||||
lastPacket?.timeRemaining &&
|
||||
lastPacket.timeRemaining > 0
|
||||
? calculateETA()
|
||||
: ""}
|
||||
lastPacket.timeRemaining > 0 && (
|
||||
<>
|
||||
<ClockIcon size={14} />
|
||||
{calculateETA()}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="download-group__progress-percentage">
|
||||
{formatDownloadProgress(currentProgress)}
|
||||
<AnimatedPercentage value={currentProgress} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="download-group__progress-bar">
|
||||
@@ -355,7 +461,7 @@ export function DownloadGroup({
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const peakSpeedsRef = useRef<Record<string, number>>({});
|
||||
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
|
||||
const speedHistoryRef = useRef<Record<string, number[]>>({});
|
||||
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
||||
{}
|
||||
@@ -381,9 +487,12 @@ export function DownloadGroup({
|
||||
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
|
||||
const gameId = lastPacket.gameId;
|
||||
|
||||
const currentPeak = peakSpeedsRef.current[gameId] || 0;
|
||||
const currentPeak = peakSpeeds[gameId] || 0;
|
||||
if (lastPacket.downloadSpeed > currentPeak) {
|
||||
peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed;
|
||||
setPeakSpeeds((prev) => ({
|
||||
...prev,
|
||||
[gameId]: lastPacket.downloadSpeed,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!speedHistoryRef.current[gameId]) {
|
||||
@@ -396,7 +505,7 @@ export function DownloadGroup({
|
||||
speedHistoryRef.current[gameId].shift();
|
||||
}
|
||||
}
|
||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed]);
|
||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const game of library) {
|
||||
@@ -408,7 +517,7 @@ export function DownloadGroup({
|
||||
// Fresh download - clear any old data
|
||||
if (speedHistoryRef.current[game.id]?.length > 0) {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
peakSpeedsRef.current[game.id] = 0;
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +533,7 @@ export function DownloadGroup({
|
||||
) {
|
||||
const timeout = setTimeout(() => {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
peakSpeedsRef.current[game.id] = 0;
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}, 10_000);
|
||||
timeouts.push(timeout);
|
||||
}
|
||||
@@ -677,7 +786,7 @@ export function DownloadGroup({
|
||||
? (lastPacket?.downloadSpeed ?? 0)
|
||||
: 0;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const peakSpeed = peakSpeedsRef.current[game.id] || 0;
|
||||
const peakSpeed = peakSpeeds[game.id] || 0;
|
||||
const currentProgress =
|
||||
isGameDownloading && lastPacket
|
||||
? lastPacket.progress
|
||||
@@ -712,10 +821,11 @@ export function DownloadGroup({
|
||||
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
||||
>
|
||||
<div className="download-group__header">
|
||||
<div className="download-group__header-title-group">
|
||||
<h2>{title}</h2>
|
||||
<div className="download-group__header-divider" />
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="download-group__simple-list">
|
||||
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
|
||||
@@ -728,8 +838,20 @@ export function DownloadGroup({
|
||||
<div className="download-group__simple-info">
|
||||
<h3 className="download-group__simple-title">{game.title}</h3>
|
||||
<div className="download-group__simple-meta">
|
||||
<div className="download-group__simple-meta-row">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
<span className="download-group__simple-size">{size}</span>
|
||||
</div>
|
||||
<div className="download-group__simple-meta-row">
|
||||
{game.download?.extracting ? (
|
||||
<span className="download-group__simple-extracting">
|
||||
{t("extracting")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__simple-size">
|
||||
<DownloadIcon size={14} />
|
||||
{size}
|
||||
</span>
|
||||
)}
|
||||
{game.download?.progress === 1 && seeding && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
@@ -737,6 +859,7 @@ export function DownloadGroup({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isQueuedGroup && (
|
||||
<div className="download-group__simple-progress">
|
||||
@@ -771,8 +894,9 @@ export function DownloadGroup({
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__simple-menu-btn"
|
||||
tooltip={t("resume")}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
|
||||
Reference in New Issue
Block a user