From b89de065feeb3d02b0df58bbcdd01d419de70212 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 11 Nov 2025 18:33:28 +0000 Subject: [PATCH 001/117] ci: fixing format --- src/renderer/src/components/sidebar/sidebar.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 312cba3c..80732be8 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -128,7 +128,6 @@ border: 1px solid rgba(34, 197, 94, 0.5); } - &__section-header { display: flex; justify-content: space-between; From 436d1b74be9b1d0a5c8047d353cfe77343909d86 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 11 Nov 2025 18:35:18 +0000 Subject: [PATCH 002/117] ci: fixing format --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2fec5ee..9174ed72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.4", + "version": "3.7.5", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", From 002dff098c06a44567418b3da5e7a49d861e6126 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 21:50:48 +0200 Subject: [PATCH 003/117] fix: custom assets not being showed in library page --- .../pages/library/library-game-card-large.tsx | 24 ++++++++++++++----- .../src/pages/library/library-game-card.tsx | 6 +++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 42b4ab72..292290ca 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -12,12 +12,18 @@ interface LibraryGameCardLargeProps { ) => void; } +const normalizePathForCss = (url: string | null | undefined): string => { + if (!url) return ""; + return url.replaceAll("\\", "/"); +}; + const getImageWithCustomPriority = ( customUrl: string | null | undefined, originalUrl: string | null | undefined, fallbackUrl?: string | null | undefined ) => { - return customUrl || originalUrl || fallbackUrl || ""; + const selectedUrl = customUrl || originalUrl || fallbackUrl || ""; + return normalizePathForCss(selectedUrl); }; export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ @@ -30,15 +36,21 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ const backgroundImage = useMemo( () => getImageWithCustomPriority( + game.customHeroImageUrl, game.libraryHeroImageUrl, - game.libraryImageUrl, - game.iconUrl + game.libraryImageUrl ?? game.iconUrl ), - [game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl] + [ + game.customHeroImageUrl, + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl, + ] ); const backgroundStyle = useMemo( - () => ({ backgroundImage: `url(${backgroundImage})` }), + () => + backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, [backgroundImage] ); @@ -49,7 +61,7 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ [game.unlockedAchievementCount, game.achievementCount] ); - const logoImage = game.logoImageUrl; + const logoImage = game.customLogoImageUrl ?? game.logoImageUrl; return ( + +
+
+
+ {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 && ( From be3ce6e2db685f3897c23090abc8ef9efc2dfbee Mon Sep 17 00:00:00 2001 From: Snehit Sah Date: Fri, 14 Nov 2025 20:20:26 +0530 Subject: [PATCH 018/117] ci: fix version name in aur commit omit extra 'v' in commit message --- .github/workflows/update-aur.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index fa12b500..22fcc49a 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -137,7 +137,7 @@ jobs: if git diff --staged --quiet; then echo "No changes to commit" else - COMMIT_MSG="v${{ steps.get-version.outputs.version }}" + COMMIT_MSG="${{ steps.get-version.outputs.version }}" git commit -m "$COMMIT_MSG" From 3ff50a993241313d58db8713bb758a1d00e7c8e5 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 00:44:54 +0530 Subject: [PATCH 019/117] feat: update download group UI with hero section and speed chart integration --- .../src/pages/downloads/download-group.scss | 422 ++++++----- .../src/pages/downloads/download-group.tsx | 657 ++++++++++++------ .../src/pages/downloads/downloads.scss | 1 - 3 files changed, 683 insertions(+), 397 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 22bff527..e8549ef3 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,11 +4,13 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 2); + margin-inline: calc(globals.$spacing-unit * 3); &__header { display: flex; align-items: center; justify-content: space-between; + padding-top: calc(globals.$spacing-unit * 4); gap: calc(globals.$spacing-unit * 2); &-divider { @@ -21,164 +23,180 @@ font-weight: 400; } } - - &__downloads { + &--hero { width: 100%; - gap: calc(globals.$spacing-unit * 3); - display: flex; - flex-direction: column; + position: relative; + overflow: hidden; margin: 0; padding: 0; - margin-top: globals.$spacing-unit; + margin-bottom: calc(globals.$spacing-unit * 3); } - &__item { - width: 100%; - background-color: globals.$background-color; - display: flex; - border-radius: 8px; - border: solid 1px globals.$border-color; - overflow: hidden; - box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.5); - transition: all ease 0.2s; - 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); - } - } - - &__background-image { - width: 100%; - height: 100%; + &__hero-background { position: absolute; top: 0; left: 0; + width: 100%; + height: 120%; z-index: 0; img { width: 100%; height: 100%; object-fit: cover; - object-position: 50% 25%; + object-position: 50% 20%; } } - - &__background-overlay { + // PLEASE FIX THE COLORS + &__hero-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% + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgba(0, 0, 0, 1) 70%, + rgb(27, 27, 27) 100% ); } - &__content { + &__hero-content { position: relative; - z-index: 2; - width: 100%; - height: 100%; - display: flex; - } - - &__left-section { - flex: 1; - max-width: 50%; - height: 100%; - display: flex; - align-items: flex-end; - padding: calc(globals.$spacing-unit * 2); - } - - &__logo-container { + z-index: 1; + padding: calc(globals.$spacing-unit * 4); + padding-bottom: 0; display: flex; flex-direction: column; - gap: globals.$spacing-unit; - } - - &__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; - justify-content: space-between; gap: calc(globals.$spacing-unit * 2); } - &__stats { + &__hero-header { display: flex; - gap: calc(globals.$spacing-unit * 3); + justify-content: flex-end; + margin-bottom: calc(globals.$spacing-unit * 2); } - &__stat { + &__hero-logo { + flex: 1; + + img { + max-width: 600px; + max-height: 200px; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.8)); + } + + h1 { + font-size: 64px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9); + margin: 0; + } + } + + &__hero-actions { + display: flex; + gap: calc(globals.$spacing-unit); + align-items: center; + } + + &__hero-action-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 3); + margin-bottom: calc(globals.$spacing-unit * 3); + } + + &__hero-menu-btn { + background-color: rgba(0, 0, 0, 0.4); + padding: calc(globals.$spacing-unit); + } + + &__hero-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit); + margin-bottom: calc(globals.$spacing-unit * 3); + } + + &__progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(globals.$spacing-unit / 2); + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__progress-percentage { + font-size: 14px; + font-weight: 700; + color: #ffffff; + } + + &__progress-details { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + margin-top: calc(globals.$spacing-unit / 2); + } + + &__progress-size { + font-weight: 600; + } + + &__progress-time { + color: globals.$muted-color; + } + + &__hero-stats { + display: flex; + gap: calc(globals.$spacing-unit * 4); + padding: calc(globals.$spacing-unit * 2); + border-radius: 12px; + background: rgba(26, 26, 26, 0.1); + backdrop-filter: blur(8px); + margin-top: calc(globals.$spacing-unit * 2); + } + + &__stats-column { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + min-width: 200px; + padding-right: calc(globals.$spacing-unit * 2); + border-right: 1px solid rgba(255, 255, 255, 0.1); + } + + &__speed-chart { + flex: 1; display: flex; align-items: center; + justify-content: center; + overflow: hidden; + } + + &__speed-chart-canvas { + width: 100%; + height: 100px; + image-rendering: crisp-edges; + } + + &__stat-item { + display: flex; + align-items: flex-end; gap: calc(globals.$spacing-unit); svg { @@ -187,9 +205,8 @@ } } - &__stat-info { + &__stat-content { display: flex; - flex-direction: column; gap: 2px; } @@ -199,108 +216,135 @@ 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; + font-size: 11px; line-height: 1.2; } - &__menu-button { - 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 { + &__simple-list { + width: 100%; display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit / 2); - flex: 1; + gap: calc(globals.$spacing-unit * 2); + margin: 0; + padding: 0; + list-style: none; } - &__bottom-row { + &__simple-card { 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); + } } - &__progress-info { + &__simple-thumbnail { + width: 200px; + height: 100px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background-color: rgba(0, 0, 0, 0.3); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__simple-info { + flex: 1; + min-width: 0; display: flex; - justify-content: space-between; - font-size: 12px; - color: rgba(255, 255, 255, 0.8); + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); } - &__progress-text { + &__simple-title { + font-size: 16px; font-weight: 600; + color: #ffffff; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - &__progress-size { + &__simple-meta { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + font-size: 13px; color: globals.$muted-color; } + &__simple-size { + font-weight: 500; + } + + &__simple-seeding { + color: #4ade80; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__simple-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + width: 200px; + flex-shrink: 0; + } + + &__simple-progress-text { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + text-align: right; + } + + &__simple-actions { + flex-shrink: 0; + } + + &__simple-menu-btn { + padding: calc(globals.$spacing-unit); + min-height: unset; + } + &__progress-bar { width: 100%; - height: 6px; + height: 8px; background-color: rgba(255, 255, 255, 0.08); border-radius: 4px; overflow: hidden; position: relative; + + &--small { + height: 6px; + } } &__progress-fill { height: 100%; background-color: globals.$muted-color; - transition: width 0.3s ease; + transition: + width 0.3s ease, + background 0.35s 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 { - background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); - box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); - width: 100%; - position: absolute; - bottom: 0; - height: 2px; - z-index: 2; - } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 9f999317..64cf5803 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,5 +1,3 @@ -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; @@ -12,7 +10,7 @@ import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -27,9 +25,99 @@ import { TrashIcon, UnlinkIcon, XCircleIcon, - DatabaseIcon, GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: SpeedChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const width = canvas.width; + const height = canvas.height; + const totalBars = 120; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + const maxHeight = peakSpeed || Math.max(...speeds, 1); + + ctx.clearRect(0, 0, width, height); + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < speeds.length) { + const speed = speeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + let r = 8, + g = 234, + b = 121; + + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = parseInt(hex.substring(0, 2), 16); + g = parseInt(hex.substring(2, 4), 16); + b = parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = parseInt(matches[0]); + g = parseInt(matches[1]); + b = parseInt(matches[2]); + } + } + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + }, [speeds, peakSpeed, color]); + + return ( + + ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -65,14 +153,98 @@ export function DownloadGroup({ } = useDownload(); const peakSpeedsRef = useRef>({}); + const speedHistoryRef = useRef>({}); + const [dominantColors, setDominantColors] = useState>( + {} + ); + + const extractDominantColor = useCallback( + async (imageUrl: string, gameId: string) => { + if (dominantColors[gameId]) return; + + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = + typeof color === "string" ? color : color.toString(); + setDominantColors((prev) => ({ ...prev, [gameId]: colorString })); + } catch (error) { + console.error("Failed to extract dominant color:", error); + } + }, + [dominantColors] + ); + useEffect(() => { - if (lastPacket?.gameId && lastPacket.downloadSpeed) { - const currentPeak = peakSpeedsRef.current[lastPacket.gameId] || 0; + if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { + const gameId = lastPacket.gameId; + + const currentPeak = peakSpeedsRef.current[gameId] || 0; if (lastPacket.downloadSpeed > currentPeak) { - peakSpeedsRef.current[lastPacket.gameId] = lastPacket.downloadSpeed; + peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed; + } + + if (!speedHistoryRef.current[gameId]) { + speedHistoryRef.current[gameId] = []; + } + + speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); + + if (speedHistoryRef.current[gameId].length > 60) { + speedHistoryRef.current[gameId].shift(); } } }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + + useEffect(() => { + library.forEach((game) => { + if ( + game.download && + game.download.progress < 0.01 && + game.download.status !== "paused" + ) { + // Fresh download - clear any old data + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id] = []; + peakSpeedsRef.current[game.id] = 0; + } + } + }); + }, [library]); + + useEffect(() => { + const cleanupIntervals: NodeJS.Timeout[] = []; + + library.forEach((game) => { + if (game.download?.progress === 1 || !game.download) { + if (speedHistoryRef.current[game.id]?.length > 0) { + const interval = setInterval(() => { + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id].shift(); + } else { + clearInterval(interval); + } + }, 50); + cleanupIntervals.push(interval); + } + } + }); + + return () => { + cleanupIntervals.forEach((interval) => clearInterval(interval)); + }; + }, [library]); + + useEffect(() => { + if (library.length > 0 && title === t("download_in_progress")) { + const game = library[0]; + const heroImageUrl = + game.libraryHeroImageUrl || game.libraryImageUrl || ""; + if (heroImageUrl && game.id) { + extractDominantColor(heroImageUrl, game.id); + } + } + }, [library, title, t, extractDominantColor]); + const isGameSeeding = (game: LibraryGame) => { const entry = seedingStatus.find((s) => s.gameId === game.id); if (entry && entry.status) return entry.status === "seeding"; @@ -141,10 +313,7 @@ export function DownloadGroup({ if (lastPacket.isCheckingFiles) { return t("checking_files"); } - if (lastPacket.timeRemaining && lastPacket.timeRemaining > 0) { - return calculateETA(); - } - return t("calculating_eta"); + return t("download_in_progress"); } if (status === "paused") { @@ -160,32 +329,6 @@ export function DownloadGroup({ 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) => { await window.electron.extractGameDownload(shop, objectId); @@ -298,6 +441,209 @@ export function DownloadGroup({ if (!library.length) return null; + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); + + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; // Only one active download + 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; + + const dominantColor = dominantColors[game.id] || "#ffffff"; + + return ( + <> +
+
+ {game.title} +
+
+ +
+
+
+ + + +
+
+ +
+
+ {game.logoImageUrl ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ + {isGameDownloading ? ( + + ) : ( + + )} +
+ +
+
+ + {getStatusText(game)} + + + {formatDownloadProgress(currentProgress)} + +
+
+
{ + try { + const isPaused = game.download?.status === "paused"; + const colorToUse = isPaused + ? "#ffffff" + : dominantColor || "#ffffff"; + const hex = colorToUse; + if (hex.startsWith("#")) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + if (hex.startsWith("rgb")) { + const nums = hex.match(/\d+/g) || []; + const r = nums[0] || 8; + const g = nums[1] || 234; + const b = nums[2] || 121; + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + return undefined; + } catch (e) { + return undefined; + } + })(), + }} + /> +
+
+ + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 + ? calculateETA() + : ""} + +
+
+ +
+
+
+ + + +
+ + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
+
+ +
+ + + +
+ + {t("peak")}: + + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
+
+ + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
+
+ + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
+
+ )} +
+ +
+ +
+
+
+
+ + ); + } + return (
@@ -306,186 +652,83 @@ export function DownloadGroup({

{library.length}

-
    +
      {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; + const currentProgress = game.download?.progress || 0; return ( -
    • -
      - {game.title} -
      +
    • +
      + {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 && ( - - - - )} -
      - -
      -
      -
      - - {game.download?.extracting || isGameDeleting(game.id) - ? getStatusText(game) - : formatDownloadProgress(currentProgress)} - - {isGameDownloading && ( - - {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} - - )} -
      -
      -
      -
      - -
      - {getStatusText(game)} - {getSeedsPeersText(game) && ( - - • {getSeedsPeersText(game)} - - )} -
      -
      - -
      - {game.download?.progress === 1 ? ( - - ) : isGameDownloading ? ( - - ) : ( - - )} -
      -
      +
      +

      {game.title}

      +
      + {DOWNLOADER_NAME[game.download!.downloader]} + + {finalDownloadSize} + + {game.download?.progress === 1 && isGameSeeding(game) && ( + + {t("seeding")} + + )}
      - {game.download?.downloader === Downloader.Hydra && ( -
      + {isQueuedGroup && ( +
      + + {formatDownloadProgress(currentProgress)} + +
      +
      { + try { + const isPaused = game.download?.status === "paused"; + const colorToUse = isPaused + ? "#ffffff" + : dominantColors[game.id] || "#ffffff"; + const hex = colorToUse; + if (hex.startsWith("#")) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + if (hex.startsWith("rgb")) { + const nums = hex.match(/\d+/g) || []; + const r = nums[0] || 8; + const g = nums[1] || 234; + const b = nums[2] || 121; + return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; + } + return undefined; + } catch (e) { + return undefined; + } + })(), + }} + /> +
      +
      )} + +
      + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e..abada8d7 100644 --- a/src/renderer/src/pages/downloads/downloads.scss +++ b/src/renderer/src/pages/downloads/downloads.scss @@ -3,7 +3,6 @@ .downloads { &__container { display: flex; - padding: calc(globals.$spacing-unit * 3); flex-direction: column; width: 100%; } From 0b70a28c0873ff89ea06c6d777993f77e3f147b1 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 01:16:23 +0530 Subject: [PATCH 020/117] feat: enhance download group UI with speed chart improvements and gradient progress bar --- .../src/pages/downloads/download-group.scss | 3 +- .../src/pages/downloads/download-group.tsx | 288 +++++++++--------- 2 files changed, 145 insertions(+), 146 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index e8549ef3..0891e682 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,12 +5,12 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); margin-inline: calc(globals.$spacing-unit * 3); + margin-bottom: calc(globals.$spacing-unit * 4); &__header { display: flex; align-items: center; justify-content: space-between; - padding-top: calc(globals.$spacing-unit * 4); gap: calc(globals.$spacing-unit * 2); &-divider { @@ -166,6 +166,7 @@ gap: calc(globals.$spacing-unit * 4); padding: calc(globals.$spacing-unit * 2); border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(26, 26, 26, 0.1); backdrop-filter: blur(8px); margin-top: calc(globals.$spacing-unit * 2); diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 64cf5803..b70fbc5b 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -10,7 +10,7 @@ import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -29,6 +29,23 @@ import { } from "@primer/octicons-react"; import { average } from "color.js"; +const getProgressGradient = ( + colorHex: string, + isPaused = false +): string | undefined => { + const hex = isPaused ? "#ffffff" : colorHex || "#08ea79"; + if (!hex.startsWith("#")) return undefined; + + try { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `linear-gradient(90deg, rgba(${r},${g},${b},0.95) 0%, rgba(${r},${g},${b},0.65) 100%)`; + } catch { + return undefined; + } +}; + interface SpeedChartProps { speeds: number[]; peakSpeed: number; @@ -49,73 +66,86 @@ function SpeedChart({ const ctx = canvas.getContext("2d"); if (!ctx) return; - const width = canvas.width; - const height = canvas.height; - const totalBars = 120; - const barWidth = 4; - const barGap = 10; - const barSpacing = barWidth + barGap; - const maxHeight = peakSpeed || Math.max(...speeds, 1); + let animationFrameId: number; - ctx.clearRect(0, 0, width, height); + const draw = () => { + const clientWidth = canvas.clientWidth; + const dpr = window.devicePixelRatio || 1; - for (let i = 0; i < totalBars; i++) { - const x = i * barSpacing; + canvas.width = clientWidth * dpr; + canvas.height = 100 * dpr; + ctx.scale(dpr, dpr); - ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; - ctx.beginPath(); - ctx.roundRect(x, 0, barWidth, height, 3); - ctx.fill(); + const width = clientWidth; + const height = 100; + const totalBars = 120; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + const maxHeight = peakSpeed || Math.max(...speeds, 1); - if (i < speeds.length) { - const speed = speeds[i] || 0; - const filledHeight = (speed / maxHeight) * height; + ctx.clearRect(0, 0, width, height); - if (filledHeight > 0) { - const gradient = ctx.createLinearGradient( - 0, - height - filledHeight, - 0, - height - ); - - let r = 8, - g = 234, - b = 121; - - if (color.startsWith("#")) { - const hex = color.replace("#", ""); - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); - } else if (color.startsWith("rgb")) { - const matches = color.match(/\d+/g); - if (matches && matches.length >= 3) { - r = parseInt(matches[0]); - g = parseInt(matches[1]); - b = parseInt(matches[2]); - } - } - - gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); - gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); - - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); - ctx.fill(); + let r = 255, + g = 255, + b = 255; + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = parseInt(hex.substring(0, 2), 16); + g = parseInt(hex.substring(2, 4), 16); + b = parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = parseInt(matches[0]); + g = parseInt(matches[1]); + b = parseInt(matches[2]); } } - } + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < speeds.length) { + const speed = speeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + }; + + animationFrameId = requestAnimationFrame(draw); + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; }, [speeds, peakSpeed, color]); return ( - + ); } @@ -212,25 +242,23 @@ export function DownloadGroup({ }, [library]); useEffect(() => { - const cleanupIntervals: NodeJS.Timeout[] = []; + const timeouts: NodeJS.Timeout[] = []; library.forEach((game) => { - if (game.download?.progress === 1 || !game.download) { - if (speedHistoryRef.current[game.id]?.length > 0) { - const interval = setInterval(() => { - if (speedHistoryRef.current[game.id]?.length > 0) { - speedHistoryRef.current[game.id].shift(); - } else { - clearInterval(interval); - } - }, 50); - cleanupIntervals.push(interval); - } + if ( + game.download?.progress === 1 && + speedHistoryRef.current[game.id]?.length > 0 + ) { + const timeout = setTimeout(() => { + speedHistoryRef.current[game.id] = []; + peakSpeedsRef.current[game.id] = 0; + }, 10_000); + timeouts.push(timeout); } }); return () => { - cleanupIntervals.forEach((interval) => clearInterval(interval)); + timeouts.forEach((timeout) => clearTimeout(timeout)); }; }, [library]); @@ -251,9 +279,17 @@ export function DownloadGroup({ return game.download?.status === "seeding"; }; + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + library.forEach((game) => { + map[game.id] = lastPacket?.gameId === game.id; + }); + return map; + }, [library, lastPacket?.gameId]); + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; if (download.fileSize != null) return formatBytes(download.fileSize); @@ -284,7 +320,7 @@ export function DownloadGroup({ }; const getStatusText = (game: LibraryGame) => { - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const status = game.download?.status; if (game.download?.extracting) { @@ -306,7 +342,7 @@ export function DownloadGroup({ return t("completed"); } - if (isGameDownloading) { + if (isGameDownloading && lastPacket) { if (lastPacket.isDownloadingMetadata) { return t("downloading_metadata"); } @@ -339,7 +375,7 @@ export function DownloadGroup({ const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const deleting = isGameDeleting(game.id); @@ -445,18 +481,19 @@ export function DownloadGroup({ const isQueuedGroup = title === t("queued_downloads"); if (isDownloadingGroup && library.length > 0) { - const game = library[0]; // Only one active download - const isGameDownloading = lastPacket?.gameId === game.id; + const game = library[0]; + const isGameDownloading = isGameDownloadingMap[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; + const currentProgress = + isGameDownloading && lastPacket + ? lastPacket.progress + : game.download?.progress || 0; - const dominantColor = dominantColors[game.id] || "#ffffff"; + const dominantColor = dominantColors[game.id] || "#fff"; return ( <> @@ -498,8 +535,8 @@ export function DownloadGroup({ onClick={() => pauseDownload(game.shop, game.objectId)} className="download-group__hero-action-btn" style={{ - backgroundColor: dominantColor || "#fff", - borderColor: dominantColor || "#fff", + backgroundColor: dominantColor, + borderColor: dominantColor, }} > @@ -511,8 +548,8 @@ export function DownloadGroup({ onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__hero-action-btn" style={{ - backgroundColor: dominantColor || "#08ea79", - borderColor: dominantColor || "#08ea79", + backgroundColor: dominantColor, + borderColor: dominantColor, }} > @@ -535,31 +572,10 @@ export function DownloadGroup({ className="download-group__progress-fill" style={{ width: `${currentProgress * 100}%`, - background: (() => { - try { - const isPaused = game.download?.status === "paused"; - const colorToUse = isPaused - ? "#ffffff" - : dominantColor || "#ffffff"; - const hex = colorToUse; - if (hex.startsWith("#")) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - if (hex.startsWith("rgb")) { - const nums = hex.match(/\d+/g) || []; - const r = nums[0] || 8; - const g = nums[1] || 234; - const b = nums[2] || 121; - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - return undefined; - } catch (e) { - return undefined; - } - })(), + background: getProgressGradient( + dominantColor, + game.download?.status === "paused" + ), }} />
@@ -644,6 +660,17 @@ export function DownloadGroup({ ); } + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [library, lastPacket?.gameId] + ); + return (
@@ -653,10 +680,7 @@ export function DownloadGroup({
    - {library.map((game) => { - const finalDownloadSize = getFinalDownloadSize(game); - const currentProgress = game.download?.progress || 0; - + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return (
  • @@ -667,10 +691,8 @@ export function DownloadGroup({

    {game.title}

    {DOWNLOADER_NAME[game.download!.downloader]} - - {finalDownloadSize} - - {game.download?.progress === 1 && isGameSeeding(game) && ( + {size} + {game.download?.progress === 1 && seeding && ( {t("seeding")} @@ -681,38 +703,14 @@ export function DownloadGroup({ {isQueuedGroup && (
    - {formatDownloadProgress(currentProgress)} + {formatDownloadProgress(progress)}
    { - try { - const isPaused = game.download?.status === "paused"; - const colorToUse = isPaused - ? "#ffffff" - : dominantColors[game.id] || "#ffffff"; - const hex = colorToUse; - if (hex.startsWith("#")) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - if (hex.startsWith("rgb")) { - const nums = hex.match(/\d+/g) || []; - const r = nums[0] || 8; - const g = nums[1] || 234; - const b = nums[2] || 121; - return `linear-gradient(90deg, rgba(${r}, ${g}, ${b}, 0.95) 0%, rgba(${r}, ${g}, ${b}, 0.65) 100%)`; - } - return undefined; - } catch (e) { - return undefined; - } - })(), + width: `${progress * 100}%`, + backgroundColor: "#fff", }} />
    From cc38be4383c5ba15046cbda060693cbfce290b13 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Sat, 15 Nov 2025 11:31:39 +0530 Subject: [PATCH 021/117] Fixed linter and sonarcloud errors, refactored some functions and fixed UI padding issues with certain themes. --- .../src/pages/downloads/download-group.scss | 27 +- .../src/pages/downloads/download-group.tsx | 527 ++++++++++-------- 2 files changed, 323 insertions(+), 231 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 0891e682..4f2a6316 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,7 +5,15 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); margin-inline: calc(globals.$spacing-unit * 3); - margin-bottom: calc(globals.$spacing-unit * 4); + padding-block: calc(globals.$spacing-unit * 3); + + &--queued { + padding-bottom: 0; + } + + &--completed { + padding-top: 0; + } &__header { display: flex; @@ -29,7 +37,7 @@ overflow: hidden; margin: 0; padding: 0; - margin-bottom: calc(globals.$spacing-unit * 3); + padding-bottom: calc(globals.$spacing-unit * 3); } &__hero-background { @@ -57,8 +65,8 @@ background: linear-gradient( to bottom, rgba(0, 0, 0, 0.3) 0%, - rgba(0, 0, 0, 1) 70%, - rgb(27, 27, 27) 100% + rgb(5, 5, 5) 70%, + rgb(26, 26, 26) 100% ); } @@ -85,7 +93,6 @@ max-width: 600px; max-height: 200px; object-fit: contain; - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.8)); } h1 { @@ -113,7 +120,11 @@ &__hero-menu-btn { background-color: rgba(0, 0, 0, 0.4); - padding: calc(globals.$spacing-unit); + padding: calc(globals.$spacing-unit * 1); + min-height: unset; + } + &__hero-menu-btn:hover { + background-color: rgba(0, 0, 0, 0.8); } &__hero-progress { @@ -320,6 +331,10 @@ &__simple-actions { flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: calc(globals.$spacing-unit); } &__simple-menu-btn { diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index b70fbc5b..3f9c50ea 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -37,9 +37,9 @@ const getProgressGradient = ( if (!hex.startsWith("#")) return undefined; try { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); return `linear-gradient(90deg, rgba(${r},${g},${b},0.95) 0%, rgba(${r},${g},${b},0.65) 100%)`; } catch { return undefined; @@ -56,7 +56,7 @@ function SpeedChart({ speeds, peakSpeed, color = "rgba(255, 255, 255, 1)", -}: SpeedChartProps) { +}: Readonly) { const canvasRef = useRef(null); useEffect(() => { @@ -91,28 +91,28 @@ function SpeedChart({ b = 255; if (color.startsWith("#")) { const hex = color.replace("#", ""); - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); + r = Number.parseInt(hex.substring(0, 2), 16); + g = Number.parseInt(hex.substring(2, 4), 16); + b = Number.parseInt(hex.substring(4, 6), 16); } else if (color.startsWith("rgb")) { const matches = color.match(/\d+/g); if (matches && matches.length >= 3) { - r = parseInt(matches[0]); - g = parseInt(matches[1]); - b = parseInt(matches[2]); + r = Number.parseInt(matches[0]); + g = Number.parseInt(matches[1]); + b = Number.parseInt(matches[2]); } } + const displaySpeeds = speeds.slice(-totalBars); for (let i = 0; i < totalBars; i++) { const x = i * barSpacing; - ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; ctx.beginPath(); ctx.roundRect(x, 0, barWidth, height, 3); ctx.fill(); - if (i < speeds.length) { - const speed = speeds[i] || 0; + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; const filledHeight = (speed / maxHeight) * height; if (filledHeight > 0) { @@ -133,14 +133,13 @@ function SpeedChart({ } } } + animationFrameId = requestAnimationFrame(draw); }; animationFrameId = requestAnimationFrame(draw); return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } + cancelAnimationFrame(animationFrameId); }; }, [speeds, peakSpeed, color]); @@ -149,6 +148,201 @@ function SpeedChart({ ); } +interface HeroDownloadViewProps { + game: LibraryGame; + isGameDownloading: boolean; + downloadSpeed: number; + finalDownloadSize: string; + peakSpeed: number; + currentProgress: number; + dominantColor: string; + lastPacket: ReturnType["lastPacket"]; + speedHistory: number[]; + getGameActions: (game: LibraryGame) => DropdownMenuItem[]; + getStatusText: (game: LibraryGame) => string; + formatSpeed: (speed: number) => string; + calculateETA: () => string; + pauseDownload: (shop: GameShop, objectId: string) => void; + resumeDownload: (shop: GameShop, objectId: string) => void; + t: (key: string) => string; +} + +function HeroDownloadView({ + game, + isGameDownloading, + downloadSpeed, + finalDownloadSize, + peakSpeed, + currentProgress, + dominantColor, + lastPacket, + speedHistory, + getGameActions, + getStatusText, + formatSpeed, + calculateETA, + pauseDownload, + resumeDownload, + t, +}: Readonly) { + return ( +
    +
    + {game.title} +
    +
    + +
    +
    +
    + + + +
    +
    + +
    +
    + {game.logoImageUrl ? ( + {game.title} + ) : ( +

    {game.title}

    + )} +
    + + {isGameDownloading ? ( + + ) : ( + + )} +
    + +
    +
    + + {getStatusText(game)} + + + {formatDownloadProgress(currentProgress)} + +
    +
    +
    +
    +
    + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 + ? calculateETA() + : ""} + +
    +
    + +
    +
    +
    + + + +
    + + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
    +
    + +
    + + + +
    + {t("peak")}: + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
    +
    + + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
    +
    + + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
    +
    + )} +
    + +
    + +
    +
    +
    +
    + ); +} + export interface DownloadGroupProps { library: LibraryGame[]; title: string; @@ -219,14 +413,14 @@ export function DownloadGroup({ speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); - if (speedHistoryRef.current[gameId].length > 60) { + if (speedHistoryRef.current[gameId].length > 120) { speedHistoryRef.current[gameId].shift(); } } }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); useEffect(() => { - library.forEach((game) => { + for (const game of library) { if ( game.download && game.download.progress < 0.01 && @@ -238,13 +432,13 @@ export function DownloadGroup({ peakSpeedsRef.current[game.id] = 0; } } - }); + } }, [library]); useEffect(() => { const timeouts: NodeJS.Timeout[] = []; - library.forEach((game) => { + for (const game of library) { if ( game.download?.progress === 1 && speedHistoryRef.current[game.id]?.length > 0 @@ -255,10 +449,12 @@ export function DownloadGroup({ }, 10_000); timeouts.push(timeout); } - }); + } return () => { - timeouts.forEach((timeout) => clearTimeout(timeout)); + for (const timeout of timeouts) { + clearTimeout(timeout); + } }; }, [library]); @@ -275,15 +471,15 @@ export function DownloadGroup({ const isGameSeeding = (game: LibraryGame) => { const entry = seedingStatus.find((s) => s.gameId === game.id); - if (entry && entry.status) return entry.status === "seeding"; + if (entry?.status) return entry.status === "seeding"; return game.download?.status === "seeding"; }; const isGameDownloadingMap = useMemo(() => { const map: Record = {}; - library.forEach((game) => { + for (const game of library) { map[game.id] = lastPacket?.gameId === game.id; - }); + } return map; }, [library, lastPacket?.gameId]); @@ -306,17 +502,29 @@ export function DownloadGroup({ }; const calculateETA = () => { - if (!lastPacket || lastPacket.timeRemaining < 0) return ""; - - try { - return formatDistance( - addMilliseconds(new Date(), lastPacket.timeRemaining), - new Date(), - { addSuffix: true } - ); - } catch (err) { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { return ""; } + + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + }; + + const getCompletedStatusText = (game: LibraryGame) => { + const isTorrent = game.download?.downloader === Downloader.Torrent; + if (isTorrent) { + return isGameSeeding(game) + ? `${t("completed")} (${t("seeding")})` + : `${t("completed")} (${t("paused")})`; + } + return t("completed"); }; const getStatusText = (game: LibraryGame) => { @@ -332,14 +540,7 @@ export function DownloadGroup({ } 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"); + return getCompletedStatusText(game); } if (isGameDownloading && lastPacket) { @@ -352,17 +553,15 @@ export function DownloadGroup({ return t("download_in_progress"); } - if (status === "paused") { - return t("paused"); + switch (status) { + case "paused": + case "error": + return t("paused"); + case "waiting": + return t("calculating_eta"); + default: + return t("paused"); } - if (status === "waiting") { - return t("calculating_eta"); - } - if (status === "error") { - return t("paused"); - } - - return t("paused"); }; const extractGameDownload = useCallback( @@ -475,10 +674,22 @@ export function DownloadGroup({ ]; }; + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [library, lastPacket?.gameId] + ); + if (!library.length) return null; const isDownloadingGroup = title === t("download_in_progress"); const isQueuedGroup = title === t("queued_downloads"); + const isCompletedGroup = title === t("downloads_completed"); if (isDownloadingGroup && library.length > 0) { const game = library[0]; @@ -496,183 +707,31 @@ export function DownloadGroup({ const dominantColor = dominantColors[game.id] || "#fff"; return ( - <> -
    -
    - {game.title} -
    -
    - -
    -
    -
    - - - -
    -
    - -
    -
    - {game.logoImageUrl ? ( - {game.title} - ) : ( -

    {game.title}

    - )} -
    - - {isGameDownloading ? ( - - ) : ( - - )} -
    - -
    -
    - - {getStatusText(game)} - - - {formatDownloadProgress(currentProgress)} - -
    -
    -
    -
    -
    - - {isGameDownloading && lastPacket - ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` - : `0 B / ${finalDownloadSize}`} - - - {isGameDownloading && - lastPacket?.timeRemaining && - lastPacket.timeRemaining > 0 - ? calculateETA() - : ""} - -
    -
    - -
    -
    -
    - - - -
    - - {t("network")}: - - - {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} - -
    -
    - -
    - - - -
    - - {t("peak")}: - - - {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} - -
    -
    - - {game.download?.downloader === Downloader.Torrent && - isGameDownloading && - lastPacket && - (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( -
    -
    - - Seeds:{" "} - - {lastPacket.numSeeds} - - , Peers:{" "} - - {lastPacket.numPeers} - - -
    -
    - )} -
    - -
    - -
    -
    -
    -
    - + ); } - const downloadInfo = useMemo( - () => - library.map((game) => ({ - game, - size: getFinalDownloadSize(game), - progress: game.download?.progress || 0, - isSeeding: isGameSeeding(game), - })), - [library, lastPacket?.gameId] - ); - return ( -
    +

    {title}

    @@ -718,6 +777,24 @@ export function DownloadGroup({ )}
    + {game.download?.progress === 1 ? ( + + ) : isQueuedGroup ? ( + + ) : null} - ) : isQueuedGroup ? ( + )} + {isQueuedGroup && game.download?.progress !== 1 && ( - ) : null} + )}
  • diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 91013da0..3754ef83 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -21,7 +21,12 @@ import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; -import { useDate, useFeature, useAppDispatch } from "@renderer/hooks"; +import { + useDate, + useFeature, + useAppDispatch, + useAppSelector, +} from "@renderer/hooks"; import { clearNewDownloadOptions } from "@renderer/features"; import "./repacks-modal.scss"; @@ -68,6 +73,9 @@ export function RepacksModal({ const { formatDate } = useDate(); const navigate = useNavigate(); const dispatch = useAppDispatch(); + const userPreferences = useAppSelector( + (state) => state.userPreferences.value + ); const getHashFromMagnet = (magnet: string) => { if (!magnet || typeof magnet !== "string") { @@ -115,10 +123,12 @@ export function RepacksModal({ setIsLoadingTimestamp(false); }; - if (visible) { + if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) { fetchLastCheckTimestamp(); + } else { + setIsLoadingTimestamp(false); } - }, [visible, repacks]); + }, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]); useEffect(() => { if ( @@ -326,11 +336,13 @@ export function RepacksModal({ >

    {repack.title} - {isNewRepack(repack) && ( - - {t("new_download_option")} - - )} + {userPreferences?.enableNewDownloadOptionsBadges !== + false && + isNewRepack(repack) && ( + + {t("new_download_option")} + + )}

    {isLastDownloadedOption && ( diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index c5698ef7..0efbcb64 100644 --- a/src/renderer/src/pages/settings/settings-behavior.tsx +++ b/src/renderer/src/pages/settings/settings-behavior.tsx @@ -29,6 +29,7 @@ export function SettingsBehavior() { enableSteamAchievements: false, autoplayGameTrailers: true, hideToTrayOnGameStart: false, + enableNewDownloadOptionsBadges: true, }); const { t } = useTranslation("settings"); @@ -53,6 +54,8 @@ export function SettingsBehavior() { userPreferences.enableSteamAchievements ?? false, autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true, hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false, + enableNewDownloadOptionsBadges: + userPreferences.enableNewDownloadOptionsBadges ?? true, }); } }, [userPreferences]); @@ -209,6 +212,17 @@ export function SettingsBehavior() {
+ + + handleChange({ + enableNewDownloadOptionsBadges: + !form.enableNewDownloadOptionsBadges, + }) + } + /> ); } diff --git a/src/types/level.types.ts b/src/types/level.types.ts index fd930a12..98ae0eb2 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -124,6 +124,7 @@ export interface UserPreferences { enableSteamAchievements?: boolean; autoplayGameTrailers?: boolean; hideToTrayOnGameStart?: boolean; + enableNewDownloadOptionsBadges?: boolean; } export interface ScreenState { From 2adc132c335a834f55c10a433003418d465fada2 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 16:57:44 +0200 Subject: [PATCH 024/117] fix: removed void from main.ts --- src/main/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/main.ts b/src/main/main.ts index 81d4f53f..147ed7dd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -60,7 +60,9 @@ export const loadState = async () => { void syncDownloadSourcesFromApi(); // Check for new download options on startup (if enabled) - void DownloadSourcesChecker.checkForChanges(); + (async () => { + await DownloadSourcesChecker.checkForChanges(); + })(); WSClient.connect(); }); From 28bf7b8764d3976c1cc3fbbdb2775f6e3ae683e5 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:02:28 +0200 Subject: [PATCH 025/117] feat: search history and suggestions --- src/locales/en/translation.json | 5 + src/main/services/window-manager.ts | 2 +- src/renderer/src/components/header/header.tsx | 182 +++++++++++++- src/renderer/src/components/index.ts | 1 + .../search-dropdown/highlight-text.tsx | 106 +++++++++ .../search-dropdown/search-dropdown.scss | 122 ++++++++++ .../search-dropdown/search-dropdown.tsx | 225 ++++++++++++++++++ src/renderer/src/hooks/index.ts | 2 + src/renderer/src/hooks/use-search-history.ts | 78 ++++++ .../src/hooks/use-search-suggestions.ts | 149 ++++++++++++ 10 files changed, 864 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/search-dropdown/highlight-text.tsx create mode 100644 src/renderer/src/components/search-dropdown/search-dropdown.scss create mode 100644 src/renderer/src/components/search-dropdown/search-dropdown.tsx create mode 100644 src/renderer/src/hooks/use-search-history.ts create mode 100644 src/renderer/src/hooks/use-search-suggestions.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0..53153a93 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -94,6 +94,11 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b..2475e485 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -198,7 +198,7 @@ export class WindowManager { this.mainWindow.on("ready-to-show", () => { if (!app.isPackaged || isStaging) - WindowManager.mainWindow?.webContents.openDevTools(); + WindowManager.mainWindow?.webContents.openDevTools({ mode: "detach" }); WindowManager.mainWindow?.show(); }); diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index d3164ced..435c049b 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -3,12 +3,18 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; -import { useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { + useAppDispatch, + useAppSelector, + useSearchHistory, + useSearchSuggestions, +} from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; +import { SearchDropdown } from "@renderer/components"; const pathTitle: Record = { "/": "home", @@ -20,6 +26,7 @@ const pathTitle: Record = { export function Header() { const inputRef = useRef(null); + const searchContainerRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -37,6 +44,7 @@ export function Header() { ); const isOnLibraryPage = location.pathname.startsWith("/library"); + const isOnCataloguePage = location.pathname.startsWith("/catalogue"); const searchValue = isOnLibraryPage ? librarySearchValue @@ -45,9 +53,29 @@ export function Header() { const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [dropdownPosition, setDropdownPosition] = useState({ + x: 0, + y: 0, + }); const { t } = useTranslation("header"); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); + + const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( + searchValue, + isOnLibraryPage, + isDropdownVisible && isFocused && !isOnCataloguePage + ); + + const historyItems = getRecentHistory( + isOnLibraryPage ? "library" : "catalogue", + 3 + ); + const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle; @@ -59,13 +87,43 @@ export function Header() { return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); + const totalItems = historyItems.length + suggestions.length; + + const updateDropdownPosition = () => { + if (searchContainerRef.current) { + const rect = searchContainerRef.current.getBoundingClientRect(); + setDropdownPosition({ + x: rect.left, + y: rect.bottom, + }); + } + }; + const focusInput = () => { setIsFocused(true); inputRef.current?.focus(); }; + const handleFocus = () => { + if (isFocused && isDropdownVisible) { + updateDropdownPosition(); + return; + } + + setIsFocused(true); + setActiveIndex(-1); + setTimeout(() => { + updateDropdownPosition(); + setIsDropdownVisible(true); + }, 220); + }; + const handleBlur = () => { - setIsFocused(false); + setTimeout(() => { + setIsFocused(false); + setIsDropdownVisible(false); + setActiveIndex(-1); + }, 200); }; const handleBackButtonClick = () => { @@ -77,10 +135,37 @@ export function Header() { dispatch(setLibrarySearchQuery(value.slice(0, 255))); } else { dispatch(setFilters({ title: value.slice(0, 255) })); - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); - } } + setActiveIndex(-1); + }; + + const executeSearch = (query: string) => { + const context = isOnLibraryPage ? "library" : "catalogue"; + if (query.trim()) { + addToHistory(query, context); + } + handleSearch(query); + + if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + + setIsDropdownVisible(false); + inputRef.current?.blur(); + }; + + const handleSelectHistory = (query: string) => { + executeSearch(query); + }; + + const handleSelectSuggestion = (suggestion: { + title: string; + objectId: string; + shop: string; + }) => { + setIsDropdownVisible(false); + inputRef.current?.blur(); + navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); }; const handleClearSearch = () => { @@ -89,14 +174,75 @@ export function Header() { } else { dispatch(setFilters({ title: "" })); } + setActiveIndex(-1); + }; + + const handleClearHistory = () => { + clearHistory(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (activeIndex >= 0 && activeIndex < totalItems) { + if (activeIndex < historyItems.length) { + handleSelectHistory(historyItems[activeIndex].query); + } else { + const suggestionIndex = activeIndex - historyItems.length; + handleSelectSuggestion(suggestions[suggestionIndex]); + } + } else if (searchValue.trim()) { + executeSearch(searchValue); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + if (!isDropdownVisible) { + setIsDropdownVisible(true); + updateDropdownPosition(); + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1)); + } else if (event.key === "Escape") { + event.preventDefault(); + setIsDropdownVisible(false); + setActiveIndex(-1); + inputRef.current?.blur(); + } + }; + + const handleCloseDropdown = () => { + setIsDropdownVisible(false); + setActiveIndex(-1); }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { + const prevPath = sessionStorage.getItem("prevPath"); + const currentPath = location.pathname; + + if ( + prevPath?.startsWith("/catalogue") && + !currentPath.startsWith("/catalogue") && + catalogueSearchValue + ) { dispatch(setFilters({ title: "" })); } + + sessionStorage.setItem("prevPath", currentPath); }, [location.pathname, catalogueSearchValue, dispatch]); + useEffect(() => { + if (!isDropdownVisible) return; + + const handleResize = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isDropdownVisible]); + return ( <>
handleSearch(event.target.value)} - onFocus={() => setIsFocused(true)} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {searchValue && ( @@ -165,6 +313,26 @@ export function Header() {
+ + 0 || + suggestions.length > 0 || + isLoadingSuggestions) + } + position={dropdownPosition} + historyItems={historyItems} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + onSelectHistory={handleSelectHistory} + onSelectSuggestion={handleSelectSuggestion} + onClearHistory={handleClearHistory} + onClose={handleCloseDropdown} + activeIndex={activeIndex} + currentQuery={searchValue} + searchContainerRef={searchContainerRef} + /> ); } diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 89dccdbc..e8876fcb 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -19,3 +19,4 @@ export * from "./context-menu/context-menu"; export * from "./game-context-menu/game-context-menu"; export * from "./game-context-menu/use-game-actions"; export * from "./star-rating/star-rating"; +export * from "./search-dropdown/search-dropdown"; diff --git a/src/renderer/src/components/search-dropdown/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx new file mode 100644 index 00000000..9f8f0121 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -0,0 +1,106 @@ +import React from "react"; + +interface HighlightTextProps { + text: string; + query: string; +} + +export function HighlightText({ text, query }: HighlightTextProps) { + if (!query.trim()) { + return <>{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const textLower = text.toLowerCase(); + const textWords = text.split(/\b/); + const matches: Array<{ start: number; end: number; text: string }> = []; + + let currentIndex = 0; + textWords.forEach((word) => { + const wordLower = word.toLowerCase(); + + queryWords.forEach((queryWord) => { + if (wordLower === queryWord) { + matches.push({ + start: currentIndex, + end: currentIndex + word.length, + text: word, + }); + } + }); + + currentIndex += word.length; + }); + + if (matches.length === 0) { + return <>{text}; + } + + matches.sort((a, b) => a.start - b.start); + + const mergedMatches: Array<{ start: number; end: number }> = []; + + if (matches.length === 0) { + return <>{text}; + } + + let current = matches[0]; + + for (let i = 1; i < matches.length; i++) { + if (matches[i].start <= current.end) { + current.end = Math.max(current.end, matches[i].end); + } else { + mergedMatches.push(current); + current = matches[i]; + } + } + mergedMatches.push(current); + + const parts: Array<{ text: string; highlight: boolean }> = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + }); + } + + return ( + <> + {parts.map((part, index) => + part.highlight ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ); +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss new file mode 100644 index 00000000..276619c2 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -0,0 +1,122 @@ +@use "../../scss/globals.scss"; + +.search-dropdown { + position: fixed; + background-color: globals.$dark-background-color; + border: 1px solid globals.$border-color; + border-radius: 8px; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + margin-top: 4px; + width: 250px; + + &__section { + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid globals.$border-color; + } + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 4px; + } + + &__section-title { + color: globals.$muted-color; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__clear-button { + color: globals.$muted-color; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all ease 0.2s; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: #dadbe1; + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + } + + &__item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + color: #dadbe1; + text-align: left; + border: none; + background: transparent; + + &:hover, + &--active { + background-color: globals.$background-color; + } + + &:focus { + outline: none; + } + } + + &__item-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: globals.$muted-color; + + &--image { + border-radius: 2px; + object-fit: cover; + } + } + + &__item-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } + + &__loading, + &__empty { + padding: 16px 12px; + text-align: center; + color: globals.$muted-color; + font-size: 14px; + } + + &__empty { + font-style: italic; + } + + &__highlight { + background-color: rgba(255, 193, 7, 0.3); + color: #ffc107; + font-weight: 600; + padding: 0 2px; + border-radius: 2px; + } +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx new file mode 100644 index 00000000..a8f08401 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import { ClockIcon, SearchIcon, TrashIcon } from "@primer/octicons-react"; +import cn from "classnames"; +import { useTranslation } from "react-i18next"; +import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; +import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions"; +import { HighlightText } from "./highlight-text"; +import "./search-dropdown.scss"; + +export interface SearchDropdownProps { + visible: boolean; + position: { x: number; y: number }; + historyItems: SearchHistoryEntry[]; + suggestions: SearchSuggestion[]; + isLoadingSuggestions: boolean; + onSelectHistory: (query: string) => void; + onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onClearHistory: () => void; + onClose: () => void; + activeIndex: number; + currentQuery: string; + searchContainerRef?: React.RefObject; +} + +export function SearchDropdown({ + visible, + position, + historyItems, + suggestions, + isLoadingSuggestions, + onSelectHistory, + onSelectSuggestion, + onClearHistory, + onClose, + activeIndex, + currentQuery, + searchContainerRef, +}: SearchDropdownProps) { + const dropdownRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const { t } = useTranslation("header"); + + useEffect(() => { + if (!visible) { + setAdjustedPosition(position); + return; + } + + const checkPosition = () => { + if (!dropdownRef.current) return; + + const rect = dropdownRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = position.x; + let adjustedY = position.y; + + if (adjustedX + 250 > viewportWidth - 10) { + adjustedX = Math.max(10, viewportWidth - 250 - 10); + } + + if (adjustedY + rect.height > viewportHeight - 10) { + adjustedY = Math.max(10, viewportHeight - rect.height - 10); + } + + setAdjustedPosition({ x: adjustedX, y: adjustedY }); + }; + + requestAnimationFrame(checkPosition); + }, [visible, position]); + + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if ( + dropdownRef.current && + !dropdownRef.current.contains(target) && + !searchContainerRef?.current?.contains(target) + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [visible, onClose, searchContainerRef]); + + const handleItemClick = useCallback( + ( + type: "history" | "suggestion", + item: SearchHistoryEntry | SearchSuggestion + ) => { + if (type === "history") { + onSelectHistory((item as SearchHistoryEntry).query); + } else { + onSelectSuggestion(item as SearchSuggestion); + } + }, + [onSelectHistory, onSelectSuggestion] + ); + + if (!visible) return null; + + const totalItems = historyItems.length + suggestions.length; + const hasHistory = historyItems.length > 0; + const hasSuggestions = suggestions.length > 0; + + const getItemIndex = ( + section: "history" | "suggestion", + indexInSection: number + ) => { + if (section === "history") { + return indexInSection; + } + return historyItems.length + indexInSection; + }; + + const dropdownContent = ( +
+ {hasHistory && ( +
+
+ + {t("recent_searches")} + + +
+
    + {historyItems.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} +
+ ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..4c3c1bd2 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,3 +8,5 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..dac6d391 --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,78 @@ +import { useState, useCallback, useEffect } from "react"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const STORAGE_KEY = "search-history"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as SearchHistoryEntry[]; + setHistory(parsed); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const addToHistory = useCallback( + (query: string, context: "library" | "catalogue") => { + if (!query.trim()) return; + + const newEntry: SearchHistoryEntry = { + query: query.trim(), + timestamp: Date.now(), + context, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() + ); + const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const getRecentHistory = useCallback( + (context: "library" | "catalogue", limit: number = 3) => { + return history + .filter((entry) => entry.context === context) + .slice(0, limit); + }, + [history] + ); + + return { + history, + addToHistory, + removeFromHistory, + clearHistory, + getRecentHistory, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..f2baa8db --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + source: "library" | "catalogue"; +} + +export function useSearchSuggestions( + query: string, + isOnLibraryPage: boolean, + enabled: boolean = true +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + + const getLibrarySuggestions = useCallback( + (searchQuery: string, limit: number = 3): SearchSuggestion[] => { + if (!searchQuery.trim()) return []; + + const queryLower = searchQuery.toLowerCase(); + const matches: SearchSuggestion[] = []; + + for (const game of library) { + if (matches.length >= limit) break; + + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + if (queryIndex === queryLower.length) { + matches.push({ + title: game.title, + objectId: game.objectId, + shop: game.shop, + iconUrl: game.iconUrl, + source: "library", + }); + } + } + + return matches; + }, + [library] + ); + + const fetchCatalogueSuggestions = useCallback( + async (searchQuery: string, limit: number = 3) => { + if (!searchQuery.trim() || searchQuery.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + Array<{ + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + }> + >("/catalogue/search/suggestions", { + params: { + query: searchQuery, + limit, + }, + needsAuth: false, + }); + + if (abortController.signal.aborted) return; + + const catalogueSuggestions: SearchSuggestion[] = response.map( + (item) => ({ + ...item, + source: "catalogue" as const, + }) + ); + + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, + [] + ); + + const debouncedFetchCatalogue = useRef( + debounce(fetchCatalogueSuggestions, 300) + ).current; + + useEffect(() => { + if (!enabled || !query || query.length < 2) { + setSuggestions([]); + setIsLoading(false); + abortControllerRef.current?.abort(); + debouncedFetchCatalogue.cancel(); + return; + } + + if (isOnLibraryPage) { + const librarySuggestions = getLibrarySuggestions(query, 3); + setSuggestions(librarySuggestions); + setIsLoading(false); + } else { + debouncedFetchCatalogue(query, 3); + } + + return () => { + debouncedFetchCatalogue.cancel(); + abortControllerRef.current?.abort(); + }; + }, [ + query, + isOnLibraryPage, + enabled, + getLibrarySuggestions, + debouncedFetchCatalogue, + ]); + + return { suggestions, isLoading }; +} From 8cd613e3b6634d7bee546e77f7c7658ca2782f55 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:04:08 +0200 Subject: [PATCH 026/117] fix: removed unused variables --- src/renderer/src/components/header/header.tsx | 3 +-- src/renderer/src/components/search-dropdown/highlight-text.tsx | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 435c049b..518e4b9f 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -62,8 +62,7 @@ export function Header() { const { t } = useTranslation("header"); - const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = - useSearchHistory(); + const { addToHistory, clearHistory, getRecentHistory } = useSearchHistory(); const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( searchValue, diff --git a/src/renderer/src/components/search-dropdown/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx index 9f8f0121..9950c8a1 100644 --- a/src/renderer/src/components/search-dropdown/highlight-text.tsx +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -19,7 +19,6 @@ export function HighlightText({ text, query }: HighlightTextProps) { return <>{text}; } - const textLower = text.toLowerCase(); const textWords = text.split(/\b/); const matches: Array<{ start: number; end: number; text: string }> = []; From 9979e92d8f20e665742d5bb4d3af27c8df705440 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:05:51 +0200 Subject: [PATCH 027/117] fix: reverted detach mode for devtools window --- src/main/services/window-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 2475e485..b11b4a9b 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -198,7 +198,7 @@ export class WindowManager { this.mainWindow.on("ready-to-show", () => { if (!app.isPackaged || isStaging) - WindowManager.mainWindow?.webContents.openDevTools({ mode: "detach" }); + WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); }); From 093a9f251e94abfe367b7b2ac24d7d426e47af5d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:10:33 +0200 Subject: [PATCH 028/117] feat: selective history removal --- src/locales/en/translation.json | 1 + src/renderer/src/components/header/header.tsx | 8 ++++- .../search-dropdown/search-dropdown.scss | 31 +++++++++++++++++++ .../search-dropdown/search-dropdown.tsx | 26 ++++++++++++++-- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 53153a93..d2665928 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -97,6 +97,7 @@ "recent_searches": "Recent Searches", "suggestions": "Suggestions", "clear_history": "Clear history", + "remove_from_history": "Remove from history", "loading": "Loading...", "no_results": "No results", "home": "Home", diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 518e4b9f..5f2c1d1d 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -62,7 +62,8 @@ export function Header() { const { t } = useTranslation("header"); - const { addToHistory, clearHistory, getRecentHistory } = useSearchHistory(); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( searchValue, @@ -176,6 +177,10 @@ export function Header() { setActiveIndex(-1); }; + const handleRemoveHistoryItem = (query: string) => { + removeFromHistory(query); + }; + const handleClearHistory = () => { clearHistory(); }; @@ -326,6 +331,7 @@ export function Header() { isLoadingSuggestions={isLoadingSuggestions} onSelectHistory={handleSelectHistory} onSelectSuggestion={handleSelectSuggestion} + onRemoveHistoryItem={handleRemoveHistoryItem} onClearHistory={handleClearHistory} onClose={handleCloseDropdown} activeIndex={activeIndex} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 276619c2..78a6fac1 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -57,6 +57,37 @@ margin: 0; } + &__item-container { + position: relative; + display: flex; + align-items: center; + + &:hover .search-dropdown__item-remove { + opacity: 1; + } + } + + &__item-remove { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + color: globals.$muted-color; + padding: 4px; + border-radius: 4px; + opacity: 0; + transition: all ease 0.15s; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + + &:hover { + color: #ff5555; + background-color: rgba(255, 85, 85, 0.1); + } + } + &__item { width: 100%; display: flex; diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index a8f08401..d90c3bf5 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { createPortal } from "react-dom"; -import { ClockIcon, SearchIcon, TrashIcon } from "@primer/octicons-react"; +import { + ClockIcon, + SearchIcon, + TrashIcon, + XIcon, +} from "@primer/octicons-react"; import cn from "classnames"; import { useTranslation } from "react-i18next"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; @@ -16,6 +21,7 @@ export interface SearchDropdownProps { isLoadingSuggestions: boolean; onSelectHistory: (query: string) => void; onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onRemoveHistoryItem: (query: string) => void; onClearHistory: () => void; onClose: () => void; activeIndex: number; @@ -31,6 +37,7 @@ export function SearchDropdown({ isLoadingSuggestions, onSelectHistory, onSelectSuggestion, + onRemoveHistoryItem, onClearHistory, onClose, activeIndex, @@ -146,7 +153,10 @@ export function SearchDropdown({
    {historyItems.map((item, index) => ( -
  • +
  • +
  • ))}
From 6df34e7f3cc011ab349e1500c47841808f9d7970 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:45:32 -0300 Subject: [PATCH 029/117] chore: update hydra docs link on PR template --- .github/ISSUE_TEMPLATE/bug_report.yml | 65 ---------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 37 ------------ .github/pull-request-template.md | 4 +- 3 files changed, 1 insertion(+), 105 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index e9a91e0c..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report -description: Create a report to help us improve. Write in English. -title: "[BUG] Write a title for your bug" -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thank you for creating a bug report to help us improve! - - type: textarea - id: bug-description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - type: textarea - id: bug-reproduce - attributes: - label: Steps to Reproduce - description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error" - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. - validations: - required: false - - type: textarea - id: additional-info - attributes: - label: Additional information and data - description: | - Add screenshots and upload your all logs file here. - Logs location on Windows: "%appdata%/hydralauncher/logs" - Logs location on Linux: "~/.config/hydralauncher/logs" - validations: - required: true - - type: input - id: OS - attributes: - label: Operating System - description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)? - validations: - required: true - - type: input - id: hydra-version - attributes: - label: Hydra Version - description: Please provide the version of Hydra you are using. - validations: - required: true - - type: checkboxes - id: terms - attributes: - label: Before opening this Issue - options: - - label: I have searched the issues of this repository and believe that this is not a duplicate. - required: true - - label: I am aware that Hydra team does not offer any support or help regarding the downloaded games. - required: true - - label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ). - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 295cee45..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Feature Request -description: Request a new feature. -title: "[REQUEST] " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thank you for taking the time to suggest a new feature! - - type: textarea - id: problem-related - attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - validations: - required: false - - type: textarea - id: additional-context - attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here. - validations: - required: false diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 3653dd16..22223374 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -2,11 +2,9 @@ **When submitting this pull request, I confirm the following (please check the boxes):** -- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute). +- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html). - [ ] I have checked that there are no duplicate pull requests related to this request. - [ ] I have considered, and confirm that this submission is valuable to others. - [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers. **Fill in the PR content:** - -- From a1117c82699193a9f1f6a64b459f6f1cb1ce761e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 22 Nov 2025 07:26:48 +0200 Subject: [PATCH 030/117] feat: improving suggestion dropdown design --- .../search-dropdown/search-dropdown.scss | 24 ++++++------------- .../search-dropdown/search-dropdown.tsx | 12 +++------- .../src/hooks/use-search-suggestions.ts | 11 +++++++++ 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 78a6fac1..4b1983d1 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -24,7 +24,8 @@ display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px 4px; + padding: 8px 12px 8px; + margin-bottom: 4px; } &__section-title { @@ -35,19 +36,15 @@ letter-spacing: 0.5px; } - &__clear-button { + &__clear-text-button { color: globals.$muted-color; cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: all ease 0.2s; - display: flex; - align-items: center; - justify-content: center; + padding: 2px 6px; + font-size: 11px; + transition: color ease 0.2s; &:hover { color: #dadbe1; - background-color: rgba(255, 255, 255, 0.1); } } @@ -74,18 +71,11 @@ transform: translateY(-50%); color: globals.$muted-color; padding: 4px; - border-radius: 4px; opacity: 0; - transition: all ease 0.15s; + transition: opacity ease 0.15s; display: flex; align-items: center; justify-content: center; - background-color: transparent; - - &:hover { - color: #ff5555; - background-color: rgba(255, 85, 85, 0.1); - } } &__item { diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index d90c3bf5..9b7af639 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -1,11 +1,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { createPortal } from "react-dom"; -import { - ClockIcon, - SearchIcon, - TrashIcon, - XIcon, -} from "@primer/octicons-react"; +import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import cn from "classnames"; import { useTranslation } from "react-i18next"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; @@ -144,11 +139,10 @@ export function SearchDropdown({