mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
chore: merge with main
This commit is contained in:
commit
1e52a21349
47 changed files with 1337 additions and 451 deletions
|
@ -4,9 +4,14 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hydra</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<h1>hello</h1>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
|
||||
|
@ -23,8 +23,9 @@ export function Modal({
|
|||
}: ModalProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const modalContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleCloseClick = () => {
|
||||
const handleCloseClick = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
const zero = performance.now();
|
||||
|
||||
|
@ -36,8 +37,44 @@ export function Modal({
|
|||
setIsClosing(false);
|
||||
}
|
||||
});
|
||||
}, [onClose]);
|
||||
|
||||
const isTopMostModal = () => {
|
||||
const openModals = document.querySelectorAll("[role=modal]");
|
||||
return (
|
||||
openModals.length &&
|
||||
openModals[openModals.length - 1] === modalContentRef.current
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopMostModal()) {
|
||||
handleCloseClick();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleCloseClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!isTopMostModal()) return;
|
||||
|
||||
const clickedOutsideContent = !modalContentRef.current.contains(
|
||||
e.target as Node
|
||||
);
|
||||
|
||||
if (clickedOutsideContent) {
|
||||
handleCloseClick();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
return () => window.removeEventListener("mousedown", onMouseDown);
|
||||
}, [handleCloseClick]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(toggleDragging(visible));
|
||||
}, [dispatch, visible]);
|
||||
|
@ -46,7 +83,11 @@ export function Modal({
|
|||
|
||||
return createPortal(
|
||||
<div className={styles.backdrop({ closing: isClosing })}>
|
||||
<div className={styles.modal({ closing: isClosing })}>
|
||||
<div
|
||||
className={styles.modal({ closing: isClosing })}
|
||||
role="modal"
|
||||
ref={modalContentRef}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
|
||||
<h3>{title}</h3>
|
||||
|
|
6
src/renderer/src/declaration.d.ts
vendored
6
src/renderer/src/declaration.d.ts
vendored
|
@ -21,8 +21,8 @@ declare global {
|
|||
startGameDownload: (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) => Promise<Game>;
|
||||
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||
|
@ -75,7 +75,7 @@ declare global {
|
|||
) => Promise<void>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: () => Promise<DiskSpace>;
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
||||
/* Misc */
|
||||
getOrCacheImage: (url: string) => Promise<string>;
|
||||
|
|
|
@ -28,10 +28,11 @@ export function useDownload() {
|
|||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) =>
|
||||
window.electron
|
||||
.startGameDownload(repackId, objectID, title, shop)
|
||||
.startGameDownload(repackId, objectID, title, shop, downloadPath)
|
||||
.then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
|
|
@ -19,15 +19,15 @@ import { useAppDispatch, useDownload } from "@renderer/hooks";
|
|||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
||||
import { vars } from "../../theme.css";
|
||||
import Lottie from "lottie-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { DescriptionHeader } from "./description-header";
|
||||
import { GameDetailsSkeleton } from "./game-details-skeleton";
|
||||
import * as styles from "./game-details.css";
|
||||
import { HeroPanel } from "./hero-panel";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { RepacksModal } from "./repacks-modal";
|
||||
import Lottie from "lottie-react";
|
||||
import { DescriptionHeader } from "./description-header";
|
||||
|
||||
export function GameDetails() {
|
||||
const { objectID, shop } = useParams();
|
||||
|
@ -51,6 +51,7 @@ export function GameDetails() {
|
|||
const { t, i18n } = useTranslation("game_details");
|
||||
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
|
||||
const randomGameObjectID = useRef<string | null>(null);
|
||||
|
||||
|
@ -140,15 +141,20 @@ export function GameDetails() {
|
|||
};
|
||||
}, [game?.id, isGamePlaying, getGame]);
|
||||
|
||||
const handleStartDownload = async (repackId: number) => {
|
||||
const handleStartDownload = async (
|
||||
repackId: number,
|
||||
downloadPath: string
|
||||
) => {
|
||||
return startDownload(
|
||||
repackId,
|
||||
gameDetails.objectID,
|
||||
gameDetails.name,
|
||||
shop as GameShop
|
||||
shop as GameShop,
|
||||
downloadPath
|
||||
).then(() => {
|
||||
getGame();
|
||||
setShowRepacksModal(false);
|
||||
setShowSelectFolderModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -173,6 +179,8 @@ export function GameDetails() {
|
|||
visible={showRepacksModal}
|
||||
gameDetails={gameDetails}
|
||||
startDownload={handleStartDownload}
|
||||
showSelectFolderModal={showSelectFolderModal}
|
||||
setShowSelectFolderModal={setShowSelectFolderModal}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -6,28 +6,30 @@ import type { GameRepack, ShopDetails } from "@types";
|
|||
|
||||
import * as styles from "./repacks-modal.css";
|
||||
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
import { format } from "date-fns";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
import { format } from "date-fns";
|
||||
import { SelectFolderModal } from "./select-folder-modal";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
gameDetails: ShopDetails;
|
||||
startDownload: (repackId: number) => Promise<void>;
|
||||
showSelectFolderModal: boolean;
|
||||
setShowSelectFolderModal: (value: boolean) => void;
|
||||
startDownload: (repackId: number, downloadPath: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RepacksModal({
|
||||
visible,
|
||||
gameDetails,
|
||||
showSelectFolderModal,
|
||||
setShowSelectFolderModal,
|
||||
startDownload,
|
||||
onClose,
|
||||
}: RepacksModalProps) {
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace>(null);
|
||||
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
|
||||
const [repack, setRepack] = useState<GameRepack>(null);
|
||||
|
||||
const repackersFriendlyNames = useAppSelector(
|
||||
(state) => state.repackersFriendlyNames.value
|
||||
|
@ -39,21 +41,9 @@ export function RepacksModal({
|
|||
setFilteredRepacks(gameDetails.repacks);
|
||||
}, [gameDetails.repacks]);
|
||||
|
||||
const getDiskFreeSpace = () => {
|
||||
window.electron.getDiskFreeSpace().then((result) => {
|
||||
setDiskFreeSpace(result);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDiskFreeSpace();
|
||||
}, [visible]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setDownloadStarting(true);
|
||||
startDownload(repack.id).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
});
|
||||
setRepack(repack);
|
||||
setShowSelectFolderModal(true);
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
|
@ -70,11 +60,16 @@ export function RepacksModal({
|
|||
<Modal
|
||||
visible={visible}
|
||||
title={`${gameDetails.name} Repacks`}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
description={t("repacks_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<SelectFolderModal
|
||||
visible={showSelectFolderModal}
|
||||
onClose={() => setShowSelectFolderModal(false)}
|
||||
gameDetails={gameDetails}
|
||||
startDownload={startDownload}
|
||||
repack={repack}
|
||||
/>
|
||||
<div style={{ marginBottom: `${SPACING_UNIT * 2}px` }}>
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
</div>
|
||||
|
@ -85,7 +80,6 @@ export function RepacksModal({
|
|||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
disabled={downloadStarting}
|
||||
className={styles.repackButton}
|
||||
>
|
||||
<p style={{ color: "#DADBE1" }}>{repack.title}</p>
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const container = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
export const downloadsPathField = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const hintText = style({
|
||||
fontSize: "12px",
|
||||
color: vars.color.bodyText,
|
||||
});
|
115
src/renderer/src/pages/game-details/select-folder-modal.tsx
Normal file
115
src/renderer/src/pages/game-details/select-folder-modal.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { GameRepack, ShopDetails } from "@types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { DiskSpace } from "check-disk-space";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as styles from "./select-folder-modal.css";
|
||||
|
||||
export interface SelectFolderModalProps {
|
||||
visible: boolean;
|
||||
gameDetails: ShopDetails;
|
||||
onClose: () => void;
|
||||
startDownload: (repackId: number, downloadPath: string) => Promise<void>;
|
||||
repack: GameRepack;
|
||||
}
|
||||
|
||||
export function SelectFolderModal({
|
||||
visible,
|
||||
gameDetails,
|
||||
onClose,
|
||||
startDownload,
|
||||
repack,
|
||||
}: SelectFolderModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
visible && getDiskFreeSpace(selectedPath);
|
||||
}, [visible, selectedPath]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
window.electron.getDefaultDownloadsPath(),
|
||||
window.electron.getUserPreferences(),
|
||||
]).then(([path, userPreferences]) => {
|
||||
setSelectedPath(userPreferences?.downloadsPath || path);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getDiskFreeSpace = (path: string) => {
|
||||
window.electron.getDiskFreeSpace(path).then((result) => {
|
||||
setDiskFreeSpace(result);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: selectedPath,
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
setSelectedPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
setDownloadStarting(true);
|
||||
startDownload(repack.id, selectedPath).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={`${gameDetails.name} Installation folder`}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
label={t("downloads_path")}
|
||||
value={selectedPath}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
disabled={downloadStarting}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={styles.hintText}>
|
||||
{t("select_folder_hint")}{" "}
|
||||
<Link
|
||||
to="/settings"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "#C0C1C7",
|
||||
}}
|
||||
>
|
||||
{t("settings")}
|
||||
</Link>
|
||||
</p>
|
||||
<Button onClick={handleStartClick} disabled={downloadStarting}>
|
||||
{t("download_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue