mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-31 14:51:02 +00:00
Merge branch 'main' into fix/LBX-454
This commit is contained in:
@@ -61,10 +61,26 @@
|
||||
cursor: pointer;
|
||||
transition: all ease 0.2s;
|
||||
padding: globals.$spacing-unit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: #dadbe1;
|
||||
}
|
||||
|
||||
&--scanning svg {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__section {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
SyncIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
|
||||
import "./header.scss";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
import { ScanGamesModal } from "./scan-games-modal";
|
||||
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
||||
import cn from "classnames";
|
||||
import { SearchDropdown } from "@renderer/components";
|
||||
@@ -29,9 +36,11 @@ const pathTitle: Record<string, string> = {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scanButtonTooltipId = useId();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||
(state) => state.window
|
||||
@@ -61,6 +70,12 @@ export function Header() {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [showScanModal, setShowScanModal] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
@@ -224,6 +239,25 @@ export function Header() {
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (isScanning) return;
|
||||
|
||||
setIsScanning(true);
|
||||
setScanResult(null);
|
||||
setShowScanModal(false);
|
||||
|
||||
try {
|
||||
const result = await window.electron.scanInstalledGames();
|
||||
setScanResult(result);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearScanResult = () => {
|
||||
setScanResult(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownVisible) return;
|
||||
|
||||
@@ -235,6 +269,14 @@ export function Header() {
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isDropdownVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("openScanModal") === "true") {
|
||||
setShowScanModal(true);
|
||||
searchParams.delete("openScanModal");
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -265,6 +307,21 @@ export function Header() {
|
||||
</section>
|
||||
|
||||
<section className="header__section">
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("header__action-button", {
|
||||
"header__action-button--scanning": isScanning,
|
||||
})}
|
||||
onClick={() => setShowScanModal(true)}
|
||||
data-tooltip-id={scanButtonTooltipId}
|
||||
data-tooltip-content={t("scan_games_tooltip")}
|
||||
data-tooltip-place="bottom"
|
||||
>
|
||||
<SyncIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={cn("header__search", {
|
||||
@@ -304,6 +361,11 @@ export function Header() {
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
|
||||
)}
|
||||
|
||||
<AutoUpdateSubHeader />
|
||||
|
||||
<SearchDropdown
|
||||
@@ -327,6 +389,15 @@ export function Header() {
|
||||
currentQuery={searchValue}
|
||||
searchContainerRef={searchContainerRef}
|
||||
/>
|
||||
|
||||
<ScanGamesModal
|
||||
visible={showScanModal}
|
||||
onClose={() => setShowScanModal(false)}
|
||||
isScanning={isScanning}
|
||||
scanResult={scanResult}
|
||||
onStartScan={handleStartScan}
|
||||
onClearResult={handleClearScanResult}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.scan-games-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
min-width: 400px;
|
||||
|
||||
&__description {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__result {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: calc(globals.$spacing-unit * 2) 0;
|
||||
}
|
||||
|
||||
&__scanning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 3) 0;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
color: globals.$muted-color;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
&__scanning-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__games-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__game-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
border-bottom: 1px solid globals.$border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-title {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__game-path {
|
||||
color: globals.$muted-color;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
|
||||
import "./scan-games-modal.scss";
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ScanGamesModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
isScanning: boolean;
|
||||
scanResult: ScanResult | null;
|
||||
onStartScan: () => void;
|
||||
onClearResult: () => void;
|
||||
}
|
||||
|
||||
export function ScanGamesModal({
|
||||
visible,
|
||||
onClose,
|
||||
isScanning,
|
||||
scanResult,
|
||||
onStartScan,
|
||||
onClearResult,
|
||||
}: Readonly<ScanGamesModalProps>) {
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleStartScan = () => {
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
const handleScanAgain = () => {
|
||||
onClearResult();
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("scan_games_title")}
|
||||
onClose={handleClose}
|
||||
clickOutsideToClose={!isScanning}
|
||||
>
|
||||
<div className="scan-games-modal">
|
||||
{!scanResult && !isScanning && (
|
||||
<p className="scan-games-modal__description">
|
||||
{t("scan_games_description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isScanning && !scanResult && (
|
||||
<div className="scan-games-modal__scanning">
|
||||
<SyncIcon size={24} className="scan-games-modal__spinner" />
|
||||
<p className="scan-games-modal__scanning-text">
|
||||
{t("scan_games_in_progress")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<div className="scan-games-modal__results">
|
||||
{scanResult.foundGames.length > 0 ? (
|
||||
<>
|
||||
<p className="scan-games-modal__result">
|
||||
{t("scan_games_result", {
|
||||
found: scanResult.foundGames.length,
|
||||
total: scanResult.total,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<ul className="scan-games-modal__games-list">
|
||||
{scanResult.foundGames.map((game) => (
|
||||
<li
|
||||
key={game.executablePath}
|
||||
className="scan-games-modal__game-item"
|
||||
>
|
||||
<span className="scan-games-modal__game-title">
|
||||
{game.title}
|
||||
</span>
|
||||
<span className="scan-games-modal__game-path">
|
||||
{game.executablePath}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="scan-games-modal__no-results">
|
||||
{t("scan_games_no_results")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scan-games-modal__actions">
|
||||
<Button theme="outline" onClick={handleClose}>
|
||||
{scanResult ? t("scan_games_close") : t("scan_games_cancel")}
|
||||
</Button>
|
||||
{!scanResult && (
|
||||
<Button onClick={handleStartScan} disabled={isScanning}>
|
||||
{t("scan_games_start")}
|
||||
</Button>
|
||||
)}
|
||||
{scanResult && (
|
||||
<Button onClick={handleScanAgain}>
|
||||
{t("scan_games_scan_again")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,16 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
|
||||
updateGame();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -211,6 +211,10 @@ declare global {
|
||||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
scanInstalledGames: () => Promise<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
}>;
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
@@ -40,6 +40,34 @@
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
color: globals.$body-color;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
padding-right: calc(globals.$spacing-unit * 4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__availability-orb {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit * 1.5);
|
||||
right: calc(globals.$spacing-unit * 1.5);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--online {
|
||||
background-color: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
&--partial {
|
||||
background-color: #eab308;
|
||||
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
&--offline {
|
||||
background-color: #ef4444;
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&__repack-title {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
@@ -185,6 +186,20 @@ export function RepacksModal({
|
||||
);
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
const getRepackAvailabilityStatus = (
|
||||
repack: GameRepack
|
||||
): "online" | "partial" | "offline" => {
|
||||
const unavailableSet = new Set(repack.unavailableUris ?? []);
|
||||
const availableCount = repack.uris.filter(
|
||||
(uri) => !unavailableSet.has(uri)
|
||||
).length;
|
||||
const unavailableCount = repack.uris.length - availableCount;
|
||||
|
||||
if (unavailableCount === 0) return "online";
|
||||
if (availableCount === 0) return "offline";
|
||||
return "partial";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const term = filterTerm.trim().toLowerCase();
|
||||
|
||||
@@ -363,6 +378,8 @@ export function RepacksModal({
|
||||
filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption =
|
||||
checkIfLastDownloadedOption(repack);
|
||||
const availabilityStatus = getRepackAvailabilityStatus(repack);
|
||||
const tooltipId = `availability-orb-${repack.id}`;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -371,6 +388,13 @@ export function RepacksModal({
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<span
|
||||
className={`repacks-modal__availability-orb repacks-modal__availability-orb--${availabilityStatus}`}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t(`source_${availabilityStatus}`)}
|
||||
/>
|
||||
<Tooltip id={tooltipId} />
|
||||
|
||||
<p className="repacks-modal__repack-title">
|
||||
{repack.title}
|
||||
{userPreferences?.enableNewDownloadOptionsBadges !==
|
||||
|
||||
@@ -58,6 +58,8 @@ export function LocalNotificationItem({
|
||||
return <SyncIcon size={24} />;
|
||||
case "ACHIEVEMENT_UNLOCKED":
|
||||
return <TrophyIcon size={24} />;
|
||||
case "SCAN_GAMES_COMPLETE":
|
||||
return <SyncIcon size={24} />;
|
||||
default:
|
||||
return <DownloadIcon size={24} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user