mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'main' into teste-locale2
This commit is contained in:
commit
37b5cb6b60
134 changed files with 2440 additions and 2549 deletions
|
@ -26,9 +26,9 @@ globalStyle("body", {
|
|||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
fontFamily: "'Fira Mono', monospace",
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
fontSize: vars.size.body,
|
||||
background: vars.color.background,
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
margin: "0",
|
||||
});
|
||||
|
||||
|
@ -68,7 +68,7 @@ globalStyle(
|
|||
);
|
||||
|
||||
globalStyle("label", {
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
fontSize: vars.size.body,
|
||||
});
|
||||
|
||||
globalStyle("input[type=number]", {
|
||||
|
@ -79,6 +79,10 @@ globalStyle("img", {
|
|||
WebkitUserDrag: "none",
|
||||
} as Record<string, string>);
|
||||
|
||||
globalStyle("progress[value]", {
|
||||
WebkitAppearance: "none",
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Sidebar, BottomPanel, Header } from "@renderer/components";
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
|
@ -18,8 +18,8 @@ import {
|
|||
clearSearch,
|
||||
setUserPreferences,
|
||||
toggleDraggingDisabled,
|
||||
closeToast,
|
||||
} from "@renderer/features";
|
||||
import { GameStatusHelper } from "@shared";
|
||||
|
||||
document.body.classList.add(themeClass);
|
||||
|
||||
|
@ -42,6 +42,7 @@ export function App() {
|
|||
const draggingDisabled = useAppSelector(
|
||||
(state) => state.window.draggingDisabled
|
||||
);
|
||||
const toast = useAppSelector((state) => state.toast);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
|
||||
|
@ -54,7 +55,7 @@ export function App() {
|
|||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onDownloadProgress(
|
||||
(downloadProgress) => {
|
||||
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
|
||||
if (downloadProgress.game.progress === 1) {
|
||||
clearDownload();
|
||||
updateLibrary();
|
||||
return;
|
||||
|
@ -109,6 +110,10 @@ export function App() {
|
|||
});
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{window.electron.platform === "win32" && (
|
||||
|
@ -132,7 +137,15 @@ export function App() {
|
|||
</section>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<BottomPanel />
|
||||
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4.261 4.261 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 0 1 .591.108a1.745 1.745 0 0 1 .49.299l-.452.546a1.247 1.247 0 0 0-.308-.195a.91.91 0 0 0-.363-.068a.658.658 0 0 0-.28.06a.703.703 0 0 0-.224.163a.783.783 0 0 0-.151.243a.799.799 0 0 0-.056.299v.008a.852.852 0 0 0 .056.31a.7.7 0 0 0 .157.245a.736.736 0 0 0 .238.16a.774.774 0 0 0 .303.058a.79.79 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 0 1-.524.307a1.789 1.789 0 0 1-.683.123a1.642 1.642 0 0 1-.602-.107a1.46 1.46 0 0 1-.478-.3a1.371 1.371 0 0 1-.318-.455a1.438 1.438 0 0 1-.115-.58v-.008a1.426 1.426 0 0 1 .113-.57a1.449 1.449 0 0 1 .312-.46a1.418 1.418 0 0 1 .474-.309a1.58 1.58 0 0 1 .598-.111a1.708 1.708 0 0 1 .045 0zm11.963.008a2.006 2.006 0 0 1 .612.094a1.61 1.61 0 0 1 .507.277l-.386.546a1.562 1.562 0 0 0-.39-.205a1.178 1.178 0 0 0-.388-.07a.347.347 0 0 0-.208.052a.154.154 0 0 0-.07.127v.008a.158.158 0 0 0 .022.084a.198.198 0 0 0 .076.066a.831.831 0 0 0 .147.06c.062.02.14.04.236.061a3.389 3.389 0 0 1 .43.122a1.292 1.292 0 0 1 .328.17a.678.678 0 0 1 .207.24a.739.739 0 0 1 .071.337v.008a.865.865 0 0 1-.081.382a.82.82 0 0 1-.229.285a1.032 1.032 0 0 1-.353.18a1.606 1.606 0 0 1-.46.061a2.16 2.16 0 0 1-.71-.116a1.718 1.718 0 0 1-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 0 0 .236-.05a.157.157 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.204.204 0 0 0-.073-.066a.753.753 0 0 0-.143-.062a2.45 2.45 0 0 0-.233-.062a5.036 5.036 0 0 1-.413-.113a1.26 1.26 0 0 1-.331-.16a.72.72 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.863.863 0 0 1 .074-.359a.794.794 0 0 1 .214-.283a1.007 1.007 0 0 1 .34-.185a1.423 1.423 0 0 1 .448-.066a2.006 2.006 0 0 1 .025 0m-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
Before Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
Before Width: | Height: | Size: 59 KiB |
|
@ -1 +0,0 @@
|
|||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M41.4193 7.30899C41.4193 7.30899 45.3046 5.79399 44.9808 9.47328C44.8729 10.9883 43.9016 16.2908 43.1461 22.0262L40.5559 39.0159C40.5559 39.0159 40.3401 41.5048 38.3974 41.9377C36.4547 42.3705 33.5408 40.4227 33.0011 39.9898C32.5694 39.6652 24.9068 34.7955 22.2086 32.4148C21.4531 31.7655 20.5897 30.4669 22.3165 28.9519L33.6487 18.1305C34.9438 16.8319 36.2389 13.8019 30.8426 17.4812L15.7331 27.7616C15.7331 27.7616 14.0063 28.8437 10.7686 27.8698L3.75342 25.7055C3.75342 25.7055 1.16321 24.0823 5.58815 22.459C16.3807 17.3729 29.6555 12.1786 41.4193 7.30899Z" fill="currentColor"></path> </g></svg>
|
Before Width: | Height: | Size: 838 B |
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="currentColor" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M5.91992,6l14.66211,21.375l-14.35156,16.625h3.17969l12.57617,-14.57812l10,14.57813h12.01367l-15.31836,-22.33008l13.51758,-15.66992h-3.16992l-11.75391,13.61719l-9.3418,-13.61719zM9.7168,8h7.16406l23.32227,34h-7.16406z"></path></g></g></svg>
|
Before Width: | Height: | Size: 702 B |
|
@ -43,5 +43,11 @@ export const backdrop = recipe({
|
|||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
},
|
||||
windows: {
|
||||
true: {
|
||||
// SPACING_UNIT * 3 + title bar spacing
|
||||
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -7,6 +7,13 @@ export interface BackdropProps {
|
|||
|
||||
export function Backdrop({ isClosing = false, children }: BackdropProps) {
|
||||
return (
|
||||
<div className={styles.backdrop({ closing: isClosing })}>{children}</div>
|
||||
<div
|
||||
className={styles.backdrop({
|
||||
closing: isClosing,
|
||||
windows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,19 +4,21 @@ import { SPACING_UNIT, vars } from "../../theme.css";
|
|||
export const bottomPanel = style({
|
||||
width: "100%",
|
||||
borderTop: `solid 1px ${vars.color.border}`,
|
||||
backgroundColor: vars.color.background,
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: "all ease 0.2s",
|
||||
justifyContent: "space-between",
|
||||
position: "relative",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const downloadsButton = style({
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
borderBottom: "1px solid transparent",
|
||||
":hover": {
|
||||
borderBottom: `1px solid ${vars.color.bodyText}`,
|
||||
borderBottom: `1px solid ${vars.color.body}`,
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./bottom-panel.css";
|
||||
import { vars } from "../../theme.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VERSION_CODENAME } from "@renderer/constants";
|
||||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
|
||||
export function BottomPanel() {
|
||||
const { t } = useTranslation("bottom_panel");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { game, progress, downloadSpeed, eta } = useDownload();
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game && GameStatusHelper.isDownloading(game.status ?? null);
|
||||
const isGameDownloading = !!lastPacket?.game;
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
|
@ -27,17 +25,18 @@ export function BottomPanel() {
|
|||
|
||||
const status = useMemo(() => {
|
||||
if (isGameDownloading) {
|
||||
if (game.status === GameStatus.DownloadingMetadata)
|
||||
return t("downloading_metadata", { title: game.title });
|
||||
if (lastPacket?.isDownloadingMetadata)
|
||||
return t("downloading_metadata", { title: lastPacket?.game.title });
|
||||
|
||||
if (game.status === GameStatus.CheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: game.title,
|
||||
if (!eta) {
|
||||
return t("calculating_eta", {
|
||||
title: lastPacket?.game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
}
|
||||
|
||||
return t("downloading", {
|
||||
title: game?.title,
|
||||
title: lastPacket?.game.title,
|
||||
percentage: progress,
|
||||
eta,
|
||||
speed: downloadSpeed,
|
||||
|
@ -45,17 +44,18 @@ export function BottomPanel() {
|
|||
}
|
||||
|
||||
return t("no_downloads_in_progress");
|
||||
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
|
||||
}, [
|
||||
t,
|
||||
isGameDownloading,
|
||||
lastPacket?.game,
|
||||
lastPacket?.isDownloadingMetadata,
|
||||
progress,
|
||||
eta,
|
||||
downloadSpeed,
|
||||
]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={styles.bottomPanel}
|
||||
style={{
|
||||
background: isGameDownloading
|
||||
? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
|
||||
: vars.color.darkBackground,
|
||||
}}
|
||||
>
|
||||
<footer className={styles.bottomPanel}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadsButton}
|
||||
|
|
|
@ -18,7 +18,6 @@ const base = style({
|
|||
},
|
||||
":disabled": {
|
||||
opacity: vars.opacity.disabled,
|
||||
pointerEvents: "none",
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
});
|
||||
|
@ -30,6 +29,9 @@ export const button = styleVariants({
|
|||
":hover": {
|
||||
backgroundColor: "#DADBE1",
|
||||
},
|
||||
":disabled": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
],
|
||||
outline: [
|
||||
|
@ -41,6 +43,9 @@ export const button = styleVariants({
|
|||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
":disabled": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
],
|
||||
dark: [
|
||||
|
|
|
@ -109,6 +109,6 @@ export const shopIcon = style({
|
|||
});
|
||||
|
||||
export const noDownloadsLabel = style({
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
|
|
@ -2,7 +2,6 @@ import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
|
|||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg?react";
|
||||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -16,7 +15,6 @@ export interface GameCardProps
|
|||
}
|
||||
|
||||
const shopIcon = {
|
||||
epic: <EpicGamesLogo className={styles.shopIcon} />,
|
||||
steam: <SteamLogo className={styles.shopIcon} />,
|
||||
};
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ export const section = style({
|
|||
|
||||
export const backButton = recipe({
|
||||
base: {
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
WebkitAppRegion: "no-drag",
|
||||
position: "absolute",
|
||||
|
@ -145,3 +145,21 @@ export const title = recipe({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const subheader = style({
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 3}px`,
|
||||
});
|
||||
|
||||
export const newVersionButton = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
borderBottom: "1px solid transparent",
|
||||
":hover": {
|
||||
borderBottom: `1px solid ${vars.color.body}`,
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
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 {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
SyncIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./header.css";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { AppUpdaterEvents } from "@types";
|
||||
|
||||
export interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
|
@ -34,6 +40,9 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const [showUpdateSubheader, setShowUpdateSubheader] = useState(false);
|
||||
const [newVersion, setNewVersion] = useState("");
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
const title = useMemo(() => {
|
||||
|
@ -49,6 +58,30 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
}
|
||||
}, [location.pathname, search, dispatch]);
|
||||
|
||||
const handleClickRestartAndUpdate = () => {
|
||||
window.electron.restartAndInstallUpdate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAutoUpdaterEvent(
|
||||
(event: AppUpdaterEvents) => {
|
||||
if (event.type == "update-available") {
|
||||
setNewVersion(event.info.version || "");
|
||||
}
|
||||
|
||||
if (event.type == "update-downloaded") {
|
||||
setShowUpdateSubheader(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
window.electron.checkForUpdates();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focusInput = () => {
|
||||
setIsFocused(true);
|
||||
inputRef.current?.focus();
|
||||
|
@ -63,64 +96,80 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={styles.header({
|
||||
draggingDisabled,
|
||||
isWindows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton({ enabled: location.key !== "default" })}
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={location.key === "default"}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
|
||||
<h3
|
||||
className={styles.title({
|
||||
hasBackButton: location.key !== "default",
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
<>
|
||||
<header
|
||||
className={styles.header({
|
||||
draggingDisabled,
|
||||
isWindows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionButton}
|
||||
onClick={focusInput}
|
||||
className={styles.backButton({
|
||||
enabled: location.key !== "default",
|
||||
})}
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={location.key === "default"}
|
||||
>
|
||||
<SearchIcon />
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<h3
|
||||
className={styles.title({
|
||||
hasBackButton: location.key !== "default",
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={styles.actionButton}
|
||||
onClick={focusInput}
|
||||
>
|
||||
<XIcon />
|
||||
<SearchIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
{showUpdateSubheader && (
|
||||
<header className={styles.subheader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.newVersionButton}
|
||||
onClick={handleClickRestartAndUpdate}
|
||||
>
|
||||
<SyncIcon size={12} />
|
||||
<small>{t("version_available", { version: newVersion })}</small>
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,3 +9,4 @@ export * from "./text-field/text-field";
|
|||
export * from "./checkbox-field/checkbox-field";
|
||||
export * from "./link/link";
|
||||
export * from "./select/select";
|
||||
export * from "./toast/toast";
|
||||
|
|
|
@ -2,14 +2,14 @@ import { keyframes, style } from "@vanilla-extract/css";
|
|||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const modalSlideIn = keyframes({
|
||||
export const fadeIn = keyframes({
|
||||
"0%": { opacity: 0 },
|
||||
"100%": {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const modalSlideOut = keyframes({
|
||||
export const fadeOut = keyframes({
|
||||
"0%": { opacity: 1 },
|
||||
"100%": {
|
||||
opacity: 0,
|
||||
|
@ -18,12 +18,12 @@ export const modalSlideOut = keyframes({
|
|||
|
||||
export const modal = recipe({
|
||||
base: {
|
||||
animationName: modalSlideIn,
|
||||
animationName: fadeIn,
|
||||
animationDuration: "0.3s",
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "5px",
|
||||
maxWidth: "600px",
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
maxHeight: "100%",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
|
@ -33,7 +33,7 @@ export const modal = recipe({
|
|||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: modalSlideOut,
|
||||
animationName: fadeOut,
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
|
@ -65,5 +65,5 @@ export const closeModalButton = style({
|
|||
});
|
||||
|
||||
export const closeModalButtonIcon = style({
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
|
|
@ -27,7 +27,7 @@ export const content = recipe({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
paddingBottom: "0",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
},
|
||||
|
@ -118,7 +118,6 @@ export const sectionTitle = style({
|
|||
});
|
||||
|
||||
export const section = style({
|
||||
padding: `${SPACING_UNIT * 2}px 0`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
|
|
@ -10,7 +10,6 @@ import { useDownload, useLibrary } from "@renderer/hooks";
|
|||
import { routes } from "./routes";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
@ -35,14 +34,14 @@ export function Sidebar() {
|
|||
|
||||
const location = useLocation();
|
||||
|
||||
const { game: gameDownloading, progress } = useDownload();
|
||||
const { lastPacket, progress } = useDownload();
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [gameDownloading?.id, updateLibrary]);
|
||||
}, [lastPacket?.game.id, updateLibrary]);
|
||||
|
||||
const isDownloading = library.some((game) =>
|
||||
GameStatusHelper.isDownloading(game.status)
|
||||
const isDownloading = library.some(
|
||||
(game) => game.status === "active" && game.progress !== 1
|
||||
);
|
||||
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
|
@ -101,18 +100,9 @@ export function Sidebar() {
|
|||
}, [isResizing]);
|
||||
|
||||
const getGameTitle = (game: Game) => {
|
||||
if (game.status === GameStatus.Paused)
|
||||
return t("paused", { title: game.title });
|
||||
|
||||
if (gameDownloading?.id === game.id) {
|
||||
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
|
||||
|
||||
if (isVerifying)
|
||||
return t(gameDownloading.status!, {
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
if (game.status === "paused") return t("paused", { title: game.title });
|
||||
|
||||
if (lastPacket?.game.id === game.id) {
|
||||
return t("downloading", {
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
|
@ -183,7 +173,7 @@ export function Sidebar() {
|
|||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === GameStatus.Cancelled,
|
||||
muted: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
|
|
83
src/renderer/src/components/toast/toast.css.ts
Normal file
83
src/renderer/src/components/toast/toast.css.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
const TOAST_HEIGHT = 80;
|
||||
|
||||
export const slideIn = keyframes({
|
||||
"0%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
export const slideOut = keyframes({
|
||||
"0%": { transform: `translateY(0)` },
|
||||
"100%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
|
||||
});
|
||||
|
||||
export const toast = recipe({
|
||||
base: {
|
||||
animationDuration: "0.2s",
|
||||
animationTimingFunction: "ease-in-out",
|
||||
maxHeight: TOAST_HEIGHT,
|
||||
position: "fixed",
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
right: `${SPACING_UNIT * 2}px`,
|
||||
/* Bottom panel height + 16px */
|
||||
bottom: `${26 + SPACING_UNIT * 2}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
zIndex: "0",
|
||||
maxWidth: "500px",
|
||||
},
|
||||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: slideOut,
|
||||
transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)`,
|
||||
},
|
||||
false: {
|
||||
animationName: slideIn,
|
||||
transform: `translateY(0)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const toastContent = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const progress = style({
|
||||
width: "100%",
|
||||
height: "5px",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
export const closeButton = style({
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
});
|
||||
|
||||
export const successIcon = style({
|
||||
color: vars.color.success,
|
||||
});
|
||||
|
||||
export const errorIcon = style({
|
||||
color: vars.color.danger,
|
||||
});
|
103
src/renderer/src/components/toast/toast.tsx
Normal file
103
src/renderer/src/components/toast/toast.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
XCircleFillIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import * as styles from "./toast.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface ToastProps {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const INITIAL_PROGRESS = 100;
|
||||
|
||||
export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [progress, setProgress] = useState(INITIAL_PROGRESS);
|
||||
|
||||
const closingAnimation = useRef(-1);
|
||||
const progressAnimation = useRef(-1);
|
||||
|
||||
const startAnimateClosing = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
const zero = performance.now();
|
||||
|
||||
closingAnimation.current = requestAnimationFrame(
|
||||
function animateClosing(time) {
|
||||
if (time - zero <= 200) {
|
||||
closingAnimation.current = requestAnimationFrame(animateClosing);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const zero = performance.now();
|
||||
|
||||
progressAnimation.current = requestAnimationFrame(
|
||||
function animateProgress(time) {
|
||||
const elapsed = time - zero;
|
||||
|
||||
const progress = Math.min(elapsed / 2500, 1);
|
||||
const currentValue =
|
||||
INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress;
|
||||
|
||||
setProgress(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
progressAnimation.current = requestAnimationFrame(animateProgress);
|
||||
} else {
|
||||
cancelAnimationFrame(progressAnimation.current);
|
||||
startAnimateClosing();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
setProgress(INITIAL_PROGRESS);
|
||||
cancelAnimationFrame(closingAnimation.current);
|
||||
cancelAnimationFrame(progressAnimation.current);
|
||||
setIsClosing(false);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [startAnimateClosing, visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.toast({ closing: isClosing })}>
|
||||
<div className={styles.toastContent}>
|
||||
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className={styles.successIcon} />
|
||||
)}
|
||||
|
||||
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
|
||||
<span style={{ fontWeight: "bold" }}>{message}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={startAnimateClosing}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<progress className={styles.progress} value={progress} max={100} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1 +1,8 @@
|
|||
import { Downloader } from "@shared";
|
||||
|
||||
export const VERSION_CODENAME = "Exodus";
|
||||
|
||||
export const DOWNLOADER_NAME = {
|
||||
[Downloader.RealDebrid]: "Real-Debrid",
|
||||
[Downloader.Torrent]: "Torrent",
|
||||
};
|
||||
|
|
19
src/renderer/src/declaration.d.ts
vendored
19
src/renderer/src/declaration.d.ts
vendored
|
@ -8,8 +8,10 @@ import type {
|
|||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
Steam250Game,
|
||||
TorrentProgress,
|
||||
DownloadProgress,
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -21,18 +23,12 @@ declare global {
|
|||
|
||||
interface Electron {
|
||||
/* Torrenting */
|
||||
startGameDownload: (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) => Promise<Game>;
|
||||
startGameDownload: (payload: StartGameDownloadPayload) => Promise<void>;
|
||||
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||
onDownloadProgress: (
|
||||
cb: (value: TorrentProgress) => void
|
||||
cb: (value: DownloadProgress) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Catalogue */
|
||||
|
@ -67,6 +63,7 @@ declare global {
|
|||
openGame: (gameId: number, executablePath: string) => Promise<void>;
|
||||
closeGame: (gameId: number) => Promise<boolean>;
|
||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||
removeGame: (gameId: number) => Promise<void>;
|
||||
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
||||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
||||
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
||||
|
@ -78,6 +75,7 @@ declare global {
|
|||
preferences: Partial<UserPreferences>
|
||||
) => Promise<void>;
|
||||
autoLaunch: (enabled: boolean) => Promise<void>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
@ -92,13 +90,12 @@ declare global {
|
|||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Splash */
|
||||
/* Auto update */
|
||||
onAutoUpdaterEvent: (
|
||||
cb: (event: AppUpdaterEvents) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
restartAndInstallUpdate: () => Promise<void>;
|
||||
continueToMainWindow: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { TorrentProgress } from "@types";
|
||||
import type { DownloadProgress } from "@types";
|
||||
|
||||
export interface DownloadState {
|
||||
lastPacket: TorrentProgress | null;
|
||||
lastPacket: DownloadProgress | null;
|
||||
gameId: number | null;
|
||||
gamesWithDeletionInProgress: number[];
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export const downloadSlice = createSlice({
|
|||
name: "download",
|
||||
initialState,
|
||||
reducers: {
|
||||
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId) state.gameId = action.payload.game.id;
|
||||
},
|
||||
|
|
|
@ -3,3 +3,4 @@ export * from "./library-slice";
|
|||
export * from "./use-preferences-slice";
|
||||
export * from "./download-slice";
|
||||
export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
|
|
32
src/renderer/src/features/toast-slice.ts
Normal file
32
src/renderer/src/features/toast-slice.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ToastProps } from "@renderer/components/toast/toast";
|
||||
|
||||
export interface ToastState {
|
||||
message: string;
|
||||
type: ToastProps["type"];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
message: "",
|
||||
type: "success",
|
||||
visible: false,
|
||||
};
|
||||
|
||||
export const toastSlice = createSlice({
|
||||
name: "toast",
|
||||
initialState,
|
||||
reducers: {
|
||||
showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => {
|
||||
state.message = action.payload.message;
|
||||
state.type = action.payload.type;
|
||||
state.visible = true;
|
||||
},
|
||||
closeToast: (state) => {
|
||||
state.visible = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { showToast, closeToast } = toastSlice.actions;
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./use-download";
|
||||
export * from "./use-library";
|
||||
export * from "./use-date";
|
||||
export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
|
|
|
@ -9,9 +9,9 @@ import {
|
|||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
} from "@renderer/features";
|
||||
import type { GameShop, TorrentProgress } from "@types";
|
||||
import type { DownloadProgress, StartGameDownloadPayload } from "@types";
|
||||
import { useDate } from "./use-date";
|
||||
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
export function useDownload() {
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
@ -22,57 +22,54 @@ export function useDownload() {
|
|||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const startDownload = (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) =>
|
||||
window.electron
|
||||
.startGameDownload(repackId, objectID, title, shop, downloadPath)
|
||||
.then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
return game;
|
||||
});
|
||||
|
||||
const pauseDownload = (gameId: number) =>
|
||||
window.electron.pauseGameDownload(gameId).then(() => {
|
||||
const startDownload = (payload: StartGameDownloadPayload) =>
|
||||
window.electron.startGameDownload(payload).then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
return game;
|
||||
});
|
||||
|
||||
const resumeDownload = (gameId: number) =>
|
||||
window.electron.resumeGameDownload(gameId).then(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
const pauseDownload = async (gameId: number) => {
|
||||
await window.electron.pauseGameDownload(gameId);
|
||||
await updateLibrary();
|
||||
dispatch(clearDownload());
|
||||
};
|
||||
|
||||
const cancelDownload = (gameId: number) =>
|
||||
window.electron.cancelGameDownload(gameId).then(() => {
|
||||
dispatch(clearDownload());
|
||||
const resumeDownload = async (gameId: number) => {
|
||||
await window.electron.resumeGameDownload(gameId);
|
||||
return updateLibrary();
|
||||
};
|
||||
|
||||
const cancelDownload = async (gameId: number) => {
|
||||
await window.electron.cancelGameDownload(gameId);
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
const removeGameInstaller = async (gameId: number) => {
|
||||
dispatch(setGameDeleting(gameId));
|
||||
|
||||
try {
|
||||
await window.electron.deleteGameFolder(gameId);
|
||||
await window.electron.removeGame(gameId);
|
||||
updateLibrary();
|
||||
deleteGame(gameId);
|
||||
});
|
||||
} finally {
|
||||
dispatch(removeGameFromDeleting(gameId));
|
||||
}
|
||||
};
|
||||
|
||||
const removeGameFromLibrary = (gameId: number) =>
|
||||
window.electron.removeGameFromLibrary(gameId).then(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const isVerifying = GameStatusHelper.isVerifying(
|
||||
lastPacket?.game.status ?? null
|
||||
);
|
||||
|
||||
const getETA = () => {
|
||||
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
|
||||
return "";
|
||||
}
|
||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||
|
||||
try {
|
||||
return formatDistance(
|
||||
addMilliseconds(new Date(), lastPacket?.timeRemaining ?? 1),
|
||||
addMilliseconds(new Date(), lastPacket.timeRemaining),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
);
|
||||
|
@ -81,50 +78,24 @@ export function useDownload() {
|
|||
}
|
||||
};
|
||||
|
||||
const getProgress = () => {
|
||||
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
|
||||
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
|
||||
}
|
||||
|
||||
return formatDownloadProgress(lastPacket?.game.progress);
|
||||
};
|
||||
|
||||
const deleteGame = (gameId: number) =>
|
||||
window.electron
|
||||
.cancelGameDownload(gameId)
|
||||
.then(() => {
|
||||
dispatch(setGameDeleting(gameId));
|
||||
return window.electron.deleteGameFolder(gameId);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
updateLibrary();
|
||||
dispatch(removeGameFromDeleting(gameId));
|
||||
});
|
||||
|
||||
const isGameDeleting = (gameId: number) => {
|
||||
return gamesWithDeletionInProgress.includes(gameId);
|
||||
};
|
||||
|
||||
return {
|
||||
game: lastPacket?.game,
|
||||
bytesDownloaded: lastPacket?.game.bytesDownloaded,
|
||||
fileSize: lastPacket?.game.fileSize,
|
||||
isVerifying,
|
||||
gameId: lastPacket?.game.id,
|
||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||
progress: getProgress(),
|
||||
numPeers: lastPacket?.numPeers,
|
||||
numSeeds: lastPacket?.numSeeds,
|
||||
progress: formatDownloadProgress(lastPacket?.game.progress),
|
||||
lastPacket,
|
||||
eta: getETA(),
|
||||
startDownload,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
removeGameFromLibrary,
|
||||
deleteGame,
|
||||
removeGameInstaller,
|
||||
isGameDeleting,
|
||||
clearDownload: () => dispatch(clearDownload()),
|
||||
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
|
||||
setLastPacket: (packet: DownloadProgress) =>
|
||||
dispatch(setLastPacket(packet)),
|
||||
};
|
||||
}
|
||||
|
|
33
src/renderer/src/hooks/use-toast.ts
Normal file
33
src/renderer/src/hooks/use-toast.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useCallback } from "react";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { showToast } from "@renderer/features";
|
||||
|
||||
export function useToast() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const showSuccessToast = useCallback(
|
||||
(message: string) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
message,
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const showErrorToast = useCallback(
|
||||
(message: string) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
message,
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return { showSuccessToast, showErrorToast };
|
||||
}
|
|
@ -27,7 +27,6 @@ import {
|
|||
import { store } from "./store";
|
||||
|
||||
import * as resources from "@locales";
|
||||
import Splash from "./pages/splash/splash";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
|
@ -48,7 +47,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/splash" Component={Splash} />
|
||||
<Route element={<App />}>
|
||||
<Route path="/" Component={Home} />
|
||||
<Route path="/catalogue" Component={Catalogue} />
|
||||
|
|
|
@ -2,19 +2,19 @@ import { useTranslation } from "react-i18next";
|
|||
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
|
||||
import * as styles from "./delete-modal.css";
|
||||
import * as styles from "./delete-game-modal.css";
|
||||
|
||||
interface DeleteModalProps {
|
||||
interface DeleteGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
deleteGame: () => void;
|
||||
}
|
||||
|
||||
export function DeleteModal({
|
||||
export function DeleteGameModal({
|
||||
onClose,
|
||||
visible,
|
||||
deleteGame,
|
||||
}: DeleteModalProps) {
|
||||
}: DeleteGameModalProps) {
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const handleDeleteGame = () => {
|
|
@ -12,7 +12,7 @@ export const downloadTitleWrapper = style({
|
|||
export const downloadTitle = style({
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
textAlign: "left",
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
|
@ -29,7 +29,6 @@ export const downloaderName = style({
|
|||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
|
@ -46,9 +45,34 @@ export const downloadCover = style({
|
|||
width: "280px",
|
||||
minWidth: "280px",
|
||||
height: "auto",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
borderRight: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const downloadCoverContent = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-end",
|
||||
});
|
||||
|
||||
export const downloadCoverBackdrop = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const downloadCoverImage = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
zIndex: "-1",
|
||||
});
|
||||
|
||||
export const download = recipe({
|
||||
|
|
|
@ -2,21 +2,30 @@ import { useTranslation } from "react-i18next";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button, TextField } from "@renderer/components";
|
||||
import { formatDownloadProgress, steamUrlBuilder } from "@renderer/helpers";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
steamUrlBuilder,
|
||||
} from "@renderer/helpers";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
import type { Game } from "@types";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteModal } from "./delete-modal";
|
||||
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
|
||||
export function Downloads() {
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gameToBeDeleted = useRef<number | null>(null);
|
||||
|
@ -26,15 +35,13 @@ export function Downloads() {
|
|||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
lastPacket,
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
removeGameFromLibrary,
|
||||
cancelDownload,
|
||||
deleteGame,
|
||||
removeGameInstaller,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
|
@ -53,27 +60,22 @@ export function Downloads() {
|
|||
});
|
||||
|
||||
const getFinalDownloadSize = (game: Game) => {
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
||||
if (gameDownloading?.fileSize && isGameDownloading)
|
||||
return formatBytes(gameDownloading.fileSize);
|
||||
if (lastPacket?.game.fileSize && isGameDownloading)
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
};
|
||||
|
||||
const downloaderName = {
|
||||
[Downloader.RealDebrid]: t("real_debrid"),
|
||||
[Downloader.Torrent]: t("torrent"),
|
||||
};
|
||||
|
||||
const getGameInfo = (game: Game) => {
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
|
||||
if (isGameDeleting(game?.id)) {
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
|
@ -82,39 +84,30 @@ export function Downloads() {
|
|||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
{gameDownloading?.status &&
|
||||
gameDownloading?.status !== GameStatus.Downloading ? (
|
||||
<p>{t(gameDownloading?.status)}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{formatBytes(gameDownloading?.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
<p>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<p>
|
||||
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (GameStatusHelper.isReady(game?.status)) {
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<p>{game?.repack?.title}</p>
|
||||
<p>{game.repack?.title}</p>
|
||||
<p>{t("completed")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (game?.status === GameStatus.Cancelled) return <p>{t("cancelled")}</p>;
|
||||
if (game?.status === GameStatus.DownloadingMetadata)
|
||||
return <p>{t("starting_download")}</p>;
|
||||
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
if (game.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
|
@ -123,7 +116,19 @@ export function Downloads() {
|
|||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t(game.status)}</p>;
|
||||
};
|
||||
|
||||
const openDeleteModal = (gameId: number) => {
|
||||
|
@ -132,37 +137,11 @@ export function Downloads() {
|
|||
};
|
||||
|
||||
const getGameActions = (game: Game) => {
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => resumeDownload(game.id)} theme="outline">
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (GameStatusHelper.isReady(game?.status)) {
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
@ -180,18 +159,43 @@ export function Downloads() {
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === GameStatus.DownloadingMetadata) {
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
return (
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => resumeDownload(game.id)}
|
||||
theme="outline"
|
||||
disabled={
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate(`/game/${game.shop}/${game.objectID}`)}
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
|
@ -219,10 +223,9 @@ export function Downloads() {
|
|||
);
|
||||
};
|
||||
|
||||
const handleDeleteGame = () => {
|
||||
if (gameToBeDeleted.current) {
|
||||
deleteGame(gameToBeDeleted.current).then(updateLibrary);
|
||||
}
|
||||
const handleDeleteGame = async () => {
|
||||
if (gameToBeDeleted.current)
|
||||
await removeGameInstaller(gameToBeDeleted.current);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -231,7 +234,8 @@ export function Downloads() {
|
|||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
<DeleteModal
|
||||
|
||||
<DeleteGameModal
|
||||
visible={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
deleteGame={handleDeleteGame}
|
||||
|
@ -245,31 +249,36 @@ export function Downloads() {
|
|||
<li
|
||||
key={game.id}
|
||||
className={styles.download({
|
||||
cancelled: game.status === GameStatus.Cancelled,
|
||||
cancelled: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectID)}
|
||||
className={styles.downloadCover}
|
||||
alt={game.title}
|
||||
/>
|
||||
<div className={styles.downloadCover}>
|
||||
<div className={styles.downloadCoverBackdrop}>
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectID)}
|
||||
className={styles.downloadCoverImage}
|
||||
alt={game.title}
|
||||
/>
|
||||
|
||||
<div className={styles.downloadCoverContent}>
|
||||
<small className={styles.downloaderName}>
|
||||
{DOWNLOADER_NAME[game.downloader]}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.downloadRightContent}>
|
||||
<div className={styles.downloadDetails}>
|
||||
<div className={styles.downloadTitleWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
}
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<small className={styles.downloaderName}>
|
||||
{downloaderName[game?.downloader]}
|
||||
</small>
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ShopDetails } from "@types";
|
||||
|
||||
import * as styles from "./game-details.css";
|
||||
import { useContext } from "react";
|
||||
import { gameDetailsContext } from "./game-details.context";
|
||||
|
||||
export interface DescriptionHeaderProps {
|
||||
gameDetails: ShopDetails;
|
||||
}
|
||||
export function DescriptionHeader() {
|
||||
const { shopDetails } = useContext(gameDetailsContext);
|
||||
|
||||
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
if (!shopDetails) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.descriptionHeader}>
|
||||
<section className={styles.descriptionHeaderInfo}>
|
||||
<p>
|
||||
{t("release_date", {
|
||||
date: gameDetails?.release_date.date,
|
||||
date: shopDetails?.release_date.date,
|
||||
})}
|
||||
</p>
|
||||
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
|
||||
<p>{t("publisher", { publisher: shopDetails.publishers[0] })}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -67,6 +67,7 @@ export const mediaPreviewButton = recipe({
|
|||
transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
":hover": {
|
||||
opacity: "0.8",
|
||||
},
|
||||
|
@ -84,7 +85,6 @@ export const mediaPreview = style({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flex: "1",
|
||||
});
|
||||
|
||||
export const gallerySliderButton = recipe({
|
||||
|
|
|
@ -1,37 +1,36 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
|
||||
|
||||
import type { ShopDetails } from "@types";
|
||||
|
||||
import * as styles from "./gallery-slider.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "./game-details.context";
|
||||
|
||||
export interface GallerySliderProps {
|
||||
gameDetails: ShopDetails;
|
||||
}
|
||||
export function GallerySlider() {
|
||||
const { shopDetails } = useContext(gameDetailsContext);
|
||||
|
||||
export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mediaContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
|
||||
const hasMovies = gameDetails && gameDetails.movies?.length;
|
||||
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
|
||||
const hasMovies = shopDetails && shopDetails.movies?.length;
|
||||
|
||||
const [mediaCount] = useState<number>(() => {
|
||||
if (gameDetails.screenshots && gameDetails.movies) {
|
||||
return gameDetails.screenshots.length + gameDetails.movies.length;
|
||||
} else if (gameDetails.movies) {
|
||||
return gameDetails.movies.length;
|
||||
} else if (gameDetails.screenshots) {
|
||||
return gameDetails.screenshots.length;
|
||||
const mediaCount = useMemo(() => {
|
||||
if (!shopDetails) return 0;
|
||||
|
||||
if (shopDetails.screenshots && shopDetails.movies) {
|
||||
return shopDetails.screenshots.length + shopDetails.movies.length;
|
||||
} else if (shopDetails.movies) {
|
||||
return shopDetails.movies.length;
|
||||
} else if (shopDetails.screenshots) {
|
||||
return shopDetails.screenshots.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}, [shopDetails]);
|
||||
|
||||
const [mediaIndex, setMediaIndex] = useState<number>(0);
|
||||
const [mediaIndex, setMediaIndex] = useState(0);
|
||||
const [showArrows, setShowArrows] = useState(false);
|
||||
|
||||
const showNextImage = () => {
|
||||
|
@ -52,7 +51,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
|
||||
useEffect(() => {
|
||||
setMediaIndex(0);
|
||||
}, [gameDetails]);
|
||||
}, [shopDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMovies && mediaContainerRef.current) {
|
||||
|
@ -74,17 +73,17 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
const scrollLeft = mediaIndex * itemWidth;
|
||||
container.scrollLeft = scrollLeft;
|
||||
}
|
||||
}, [gameDetails, mediaIndex, mediaCount]);
|
||||
}, [shopDetails, mediaIndex, mediaCount]);
|
||||
|
||||
const previews = useMemo(() => {
|
||||
const screenshotPreviews =
|
||||
gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
|
||||
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
|
||||
id,
|
||||
thumbnail: path_thumbnail,
|
||||
})) ?? [];
|
||||
|
||||
if (gameDetails?.movies) {
|
||||
const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
|
||||
if (shopDetails?.movies) {
|
||||
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
|
||||
id,
|
||||
thumbnail,
|
||||
}));
|
||||
|
@ -93,7 +92,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
}
|
||||
|
||||
return screenshotPreviews;
|
||||
}, [gameDetails]);
|
||||
}, [shopDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -105,8 +104,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
className={styles.gallerySliderAnimationContainer}
|
||||
ref={mediaContainerRef}
|
||||
>
|
||||
{gameDetails.movies &&
|
||||
gameDetails.movies.map((video) => (
|
||||
{shopDetails.movies &&
|
||||
shopDetails.movies.map((video) => (
|
||||
<video
|
||||
key={video.id}
|
||||
controls
|
||||
|
@ -122,7 +121,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
))}
|
||||
|
||||
{hasScreenshots &&
|
||||
gameDetails.screenshots.map((image, i) => (
|
||||
shopDetails.screenshots.map((image, i) => (
|
||||
<img
|
||||
key={image.id}
|
||||
className={styles.gallerySliderMedia}
|
||||
|
|
209
src/renderer/src/pages/game-details/game-details.context.tsx
Normal file
209
src/renderer/src/pages/game-details/game-details.context.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
||||
|
||||
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DODIInstallationGuide,
|
||||
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
|
||||
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
|
||||
OnlineFixInstallationGuide,
|
||||
RepacksModal,
|
||||
} from "./modals";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
game: Game | null;
|
||||
shopDetails: ShopDetails | null;
|
||||
repacks: GameRepack[];
|
||||
gameTitle: string;
|
||||
isGameRunning: boolean;
|
||||
isLoading: boolean;
|
||||
objectID: string | undefined;
|
||||
gameColor: string;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
openRepacksModal: () => void;
|
||||
updateGame: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
shopDetails: null,
|
||||
repacks: [],
|
||||
gameTitle: "",
|
||||
isGameRunning: false,
|
||||
isLoading: false,
|
||||
objectID: undefined,
|
||||
gameColor: "",
|
||||
setGameColor: () => {},
|
||||
openRepacksModal: () => {},
|
||||
updateGame: async () => {},
|
||||
});
|
||||
|
||||
const { Provider } = gameDetailsContext;
|
||||
export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
||||
|
||||
export interface GameDetailsContextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function GameDetailsContextProvider({
|
||||
children,
|
||||
}: GameDetailsContextProps) {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [gameColor, setGameColor] = useState("");
|
||||
const [showInstructionsModal, setShowInstructionsModal] = useState<
|
||||
null | "onlinefix" | "DODI"
|
||||
>(null);
|
||||
const [isGameRunning, setisGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const gameTitle = searchParams.get("title")!;
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { startDownload, lastPacket } = useDownload();
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
|
||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
updateGame();
|
||||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
window.electron.getGameShopDetails(
|
||||
objectID!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
),
|
||||
window.electron.searchGameRepacks(gameTitle),
|
||||
])
|
||||
.then(([appDetails, repacks]) => {
|
||||
if (appDetails) setGameDetails(appDetails);
|
||||
setRepacks(repacks);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
setGameDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectID, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = [
|
||||
window.electron.onGameClose(() => {
|
||||
if (isGameRunning) setisGameRunning(false);
|
||||
}),
|
||||
window.electron.onPlaytime((gameId) => {
|
||||
if (gameId === game?.id) {
|
||||
if (!isGameRunning) setisGameRunning(true);
|
||||
updateGame();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
listeners.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => {
|
||||
await startDownload({
|
||||
repackId: repack.id,
|
||||
objectID: objectID!,
|
||||
title: gameTitle,
|
||||
downloader,
|
||||
shop: shop as GameShop,
|
||||
downloadPath,
|
||||
});
|
||||
|
||||
await updateGame();
|
||||
setShowRepacksModal(false);
|
||||
|
||||
if (
|
||||
repack.repacker === "onlinefix" &&
|
||||
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
|
||||
) {
|
||||
setShowInstructionsModal("onlinefix");
|
||||
} else if (
|
||||
repack.repacker === "DODI" &&
|
||||
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
|
||||
) {
|
||||
setShowInstructionsModal("DODI");
|
||||
}
|
||||
};
|
||||
|
||||
const openRepacksModal = () => setShowRepacksModal(true);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
game,
|
||||
shopDetails,
|
||||
repacks,
|
||||
gameTitle,
|
||||
isGameRunning,
|
||||
isLoading,
|
||||
objectID,
|
||||
gameColor,
|
||||
setGameColor,
|
||||
openRepacksModal,
|
||||
updateGame,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<RepacksModal
|
||||
visible={showRepacksModal}
|
||||
startDownload={handleStartDownload}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
|
||||
<OnlineFixInstallationGuide
|
||||
visible={showInstructionsModal === "onlinefix"}
|
||||
onClose={() => setShowInstructionsModal(null)}
|
||||
/>
|
||||
|
||||
<DODIInstallationGuide
|
||||
visible={showInstructionsModal === "DODI"}
|
||||
onClose={() => setShowInstructionsModal(null)}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</>
|
||||
</Provider>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@ import { globalStyle, keyframes, style } from "@vanilla-extract/css";
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const slideIn = keyframes({
|
||||
"0%": { transform: `translateY(${40 + 16}px)` },
|
||||
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
|
@ -174,5 +174,5 @@ globalStyle(`${description} img`, {
|
|||
});
|
||||
|
||||
globalStyle(`${description} a`, {
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
|
|
@ -1,24 +1,11 @@
|
|||
import Color from "color";
|
||||
import { average } from "color.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { average } from "color.js";
|
||||
|
||||
import {
|
||||
Steam250Game,
|
||||
type Game,
|
||||
type GameRepack,
|
||||
type GameShop,
|
||||
type ShopDetails,
|
||||
} from "@types";
|
||||
import { Steam250Game } from "@types";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
getSteamLanguage,
|
||||
steamUrlBuilder,
|
||||
} from "@renderer/helpers";
|
||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
||||
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
|
||||
|
||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
||||
|
@ -29,153 +16,34 @@ import { DescriptionHeader } from "./description-header";
|
|||
import { GameDetailsSkeleton } from "./game-details-skeleton";
|
||||
import * as styles from "./game-details.css";
|
||||
import { HeroPanel } from "./hero";
|
||||
import { RepacksModal } from "./repacks-modal";
|
||||
|
||||
import { vars } from "../../theme.css";
|
||||
import {
|
||||
DODIInstallationGuide,
|
||||
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
|
||||
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
|
||||
OnlineFixInstallationGuide,
|
||||
} from "./installation-guides";
|
||||
|
||||
import { GallerySlider } from "./gallery-slider";
|
||||
import { Sidebar } from "./sidebar/sidebar";
|
||||
import {
|
||||
GameDetailsContextConsumer,
|
||||
GameDetailsContextProvider,
|
||||
} from "./game-details.context";
|
||||
|
||||
export function GameDetails() {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||
const [color, setColor] = useState({ dark: "", light: "" });
|
||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [isGamePlaying, setIsGamePlaying] = useState(false);
|
||||
const [showInstructionsModal, setShowInstructionsModal] = useState<
|
||||
null | "onlinefix" | "DODI"
|
||||
>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { objectID } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const fromRandomizer = searchParams.get("fromRandomizer");
|
||||
const title = searchParams.get("title")!;
|
||||
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { game: gameDownloading, startDownload } = useDownload();
|
||||
|
||||
const heroImage = steamUrlBuilder.libraryHero(objectID!);
|
||||
|
||||
const handleHeroLoad = () => {
|
||||
average(heroImage, { amount: 1, format: "hex" })
|
||||
.then((color) => {
|
||||
const darkColor = new Color(color).darken(0.6).toString() as string;
|
||||
setColor({ light: color as string, dark: darkColor });
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const getGame = useCallback(() => {
|
||||
window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getGame();
|
||||
}, [getGame, gameDownloading?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setGameDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setIsGamePlaying(false);
|
||||
dispatch(setHeaderTitle(title));
|
||||
|
||||
setRandomGame(null);
|
||||
window.electron.getRandomGame().then((randomGame) => {
|
||||
setRandomGame(randomGame);
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
window.electron.getGameShopDetails(
|
||||
objectID!,
|
||||
"steam",
|
||||
getSteamLanguage(i18n.language)
|
||||
),
|
||||
window.electron.searchGameRepacks(title),
|
||||
])
|
||||
.then(([appDetails, repacks]) => {
|
||||
if (appDetails) setGameDetails(appDetails);
|
||||
setRepacks(repacks);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
getGame();
|
||||
}, [getGame, dispatch, navigate, title, objectID, i18n.language]);
|
||||
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isGameDownloading)
|
||||
setGame((prev) => {
|
||||
if (prev === null || !gameDownloading?.status) return prev;
|
||||
return { ...prev, status: gameDownloading?.status };
|
||||
});
|
||||
}, [isGameDownloading, gameDownloading?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = [
|
||||
window.electron.onGameClose(() => {
|
||||
if (isGamePlaying) setIsGamePlaying(false);
|
||||
}),
|
||||
window.electron.onPlaytime((gameId) => {
|
||||
if (gameId === game?.id) {
|
||||
if (!isGamePlaying) setIsGamePlaying(true);
|
||||
getGame();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
listeners.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [game?.id, isGamePlaying, getGame]);
|
||||
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
downloadPath: string
|
||||
) => {
|
||||
return startDownload(
|
||||
repack.id,
|
||||
objectID!,
|
||||
title,
|
||||
shop as GameShop,
|
||||
downloadPath
|
||||
).then(() => {
|
||||
getGame();
|
||||
setShowRepacksModal(false);
|
||||
|
||||
if (
|
||||
repack.repacker === "onlinefix" &&
|
||||
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
|
||||
) {
|
||||
setShowInstructionsModal("onlinefix");
|
||||
} else if (
|
||||
repack.repacker === "DODI" &&
|
||||
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
|
||||
) {
|
||||
setShowInstructionsModal("DODI");
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [objectID]);
|
||||
|
||||
const handleRandomizerClick = () => {
|
||||
if (randomGame) {
|
||||
|
@ -189,97 +57,95 @@ export function GameDetails() {
|
|||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<RepacksModal
|
||||
visible={showRepacksModal}
|
||||
repacks={repacks}
|
||||
startDownload={handleStartDownload}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
<GameDetailsContextProvider>
|
||||
<GameDetailsContextConsumer>
|
||||
{({ game, shopDetails, isLoading, setGameColor }) => {
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(
|
||||
steamUrlBuilder.libraryHero(objectID!),
|
||||
{
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
}
|
||||
);
|
||||
|
||||
<OnlineFixInstallationGuide
|
||||
visible={showInstructionsModal === "onlinefix"}
|
||||
onClose={() => setShowInstructionsModal(null)}
|
||||
/>
|
||||
setGameColor(output as string);
|
||||
};
|
||||
|
||||
<DODIInstallationGuide
|
||||
windowColor={color.light}
|
||||
visible={showInstructionsModal === "DODI"}
|
||||
onClose={() => setShowInstructionsModal(null)}
|
||||
/>
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.background}
|
||||
highlightColor="#444"
|
||||
>
|
||||
{isLoading ? (
|
||||
<GameDetailsSkeleton />
|
||||
) : (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.hero}>
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectID!)}
|
||||
className={styles.heroImage}
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
<div className={styles.heroBackdrop}>
|
||||
<div className={styles.heroContent}>
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectID!)}
|
||||
style={{ width: 300, alignSelf: "flex-end" }}
|
||||
alt={game?.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<GameDetailsSkeleton />
|
||||
) : (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.hero}>
|
||||
<img
|
||||
src={heroImage}
|
||||
className={styles.heroImage}
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
<div className={styles.heroBackdrop}>
|
||||
<div className={styles.heroContent}>
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectID!)}
|
||||
style={{ width: 300, alignSelf: "flex-end" }}
|
||||
alt={game?.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HeroPanel />
|
||||
|
||||
<HeroPanel
|
||||
game={game}
|
||||
color={color.dark}
|
||||
objectID={objectID!}
|
||||
title={title}
|
||||
repacks={repacks}
|
||||
openRepacksModal={() => setShowRepacksModal(true)}
|
||||
getGame={getGame}
|
||||
isGamePlaying={isGamePlaying}
|
||||
/>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div className={styles.descriptionContent}>
|
||||
<DescriptionHeader />
|
||||
<GallerySlider />
|
||||
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div className={styles.descriptionContent}>
|
||||
{gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
|
||||
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
shopDetails?.about_the_game ?? t("no_shop_details"),
|
||||
}}
|
||||
className={styles.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: gameDetails?.about_the_game ?? t("no_shop_details"),
|
||||
}}
|
||||
className={styles.description}
|
||||
/>
|
||||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Sidebar
|
||||
objectID={objectID!}
|
||||
title={title}
|
||||
gameDetails={gameDetails}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{fromRandomizer && (
|
||||
<Button
|
||||
className={styles.randomizerButton}
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
disabled={!randomGame}
|
||||
>
|
||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("next_suggestion")}
|
||||
</Button>
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
{fromRandomizer && (
|
||||
<Button
|
||||
className={styles.randomizerButton}
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
disabled={!randomGame}
|
||||
>
|
||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{
|
||||
width: 70,
|
||||
position: "absolute",
|
||||
top: -28,
|
||||
left: -27,
|
||||
}}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("next_suggestion")}
|
||||
</Button>
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}}
|
||||
</GameDetailsContextConsumer>
|
||||
</GameDetailsContextProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,39 +1,20 @@
|
|||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||
|
||||
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import type { Game, GameRepack } from "@types";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./hero-panel-actions.css";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export interface HeroPanelActionsProps {
|
||||
game: Game | null;
|
||||
repacks: GameRepack[];
|
||||
isGamePlaying: boolean;
|
||||
isGameDownloading: boolean;
|
||||
objectID: string;
|
||||
title: string;
|
||||
openRepacksModal: () => void;
|
||||
openBinaryNotFoundModal: () => void;
|
||||
getGame: () => void;
|
||||
}
|
||||
|
||||
export function HeroPanelActions({
|
||||
game,
|
||||
isGamePlaying,
|
||||
isGameDownloading,
|
||||
repacks,
|
||||
objectID,
|
||||
title,
|
||||
openRepacksModal,
|
||||
openBinaryNotFoundModal,
|
||||
getGame,
|
||||
}: HeroPanelActionsProps) {
|
||||
export function HeroPanelActions() {
|
||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||
useState(false);
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
|
||||
const {
|
||||
resumeDownload,
|
||||
|
@ -43,12 +24,25 @@ export function HeroPanelActions({
|
|||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
const {
|
||||
game,
|
||||
repacks,
|
||||
isGameRunning,
|
||||
objectID,
|
||||
gameTitle,
|
||||
openRepacksModal,
|
||||
updateGame,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
const userPreferences = await window.electron.getUserPreferences();
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
};
|
||||
|
@ -86,15 +80,15 @@ export function HeroPanelActions({
|
|||
const gameExecutablePath = await selectGameExecutable();
|
||||
|
||||
await window.electron.addGameToLibrary(
|
||||
objectID,
|
||||
title,
|
||||
objectID!,
|
||||
gameTitle,
|
||||
"steam",
|
||||
gameExecutablePath
|
||||
);
|
||||
}
|
||||
|
||||
updateLibrary();
|
||||
getGame();
|
||||
updateGame();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
|
@ -103,7 +97,7 @@ export function HeroPanelActions({
|
|||
const openGameInstaller = () => {
|
||||
if (game) {
|
||||
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) openBinaryNotFoundModal();
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
}
|
||||
|
@ -145,18 +139,18 @@ export function HeroPanelActions({
|
|||
</Button>
|
||||
);
|
||||
|
||||
if (game && isGameDownloading) {
|
||||
if (game?.status === "active" && game?.progress !== 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => pauseDownload(game.id)}
|
||||
onClick={() => pauseDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => cancelDownload(game.id)}
|
||||
onClick={() => cancelDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
|
@ -166,18 +160,22 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => resumeDownload(game.id)}
|
||||
onClick={() => resumeDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
disabled={
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => cancelDownload(game.id).then(getGame)}
|
||||
onClick={() => cancelDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
|
@ -187,49 +185,7 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
GameStatusHelper.isReady(game?.status ?? null) ||
|
||||
(game && !game.status)
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{GameStatusHelper.isReady(game?.status ?? null) ? (
|
||||
<Button
|
||||
onClick={openGameInstaller}
|
||||
theme="outline"
|
||||
disabled={deleting || isGamePlaying}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
) : (
|
||||
toggleGameOnLibraryButton
|
||||
)}
|
||||
|
||||
{isGamePlaying ? (
|
||||
<Button
|
||||
onClick={closeGame}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openGame}
|
||||
theme="outline"
|
||||
disabled={deleting || isGamePlaying}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("play")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === GameStatus.Cancelled) {
|
||||
if (game?.status === "removed") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
@ -240,8 +196,9 @@ export function HeroPanelActions({
|
|||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => removeGameFromLibrary(game.id).then(getGame)}
|
||||
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
|
@ -252,7 +209,7 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (repacks.length) {
|
||||
if (repacks.length && !game) {
|
||||
return (
|
||||
<>
|
||||
{toggleGameOnLibraryButton}
|
||||
|
@ -267,5 +224,47 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
return toggleGameOnLibraryButton;
|
||||
return (
|
||||
<>
|
||||
{game?.progress === 1 ? (
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={openGameInstaller}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
toggleGameOnLibraryButton
|
||||
)}
|
||||
|
||||
{isGameRunning ? (
|
||||
<Button
|
||||
onClick={closeGame}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openGame}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("play")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Game } from "@types";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
export interface HeroPanelPlaytimeProps {
|
||||
game: Game;
|
||||
isGamePlaying: boolean;
|
||||
}
|
||||
|
||||
export function HeroPanelPlaytime({
|
||||
game,
|
||||
isGamePlaying,
|
||||
}: HeroPanelPlaytimeProps) {
|
||||
export function HeroPanelPlaytime() {
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||
|
||||
const { i18n, t } = useTranslation("game_details");
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
@ -52,8 +46,8 @@ export function HeroPanelPlaytime({
|
|||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
};
|
||||
|
||||
if (!game.lastTimePlayed) {
|
||||
return <p>{t("not_played_yet", { title: game.title })}</p>;
|
||||
if (!game?.lastTimePlayed) {
|
||||
return <p>{t("not_played_yet", { title: game?.title })}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -64,7 +58,7 @@ export function HeroPanelPlaytime({
|
|||
})}
|
||||
</p>
|
||||
|
||||
{isGamePlaying ? (
|
||||
{isGameRunning ? (
|
||||
<p>{t("playing_now")}</p>
|
||||
) : (
|
||||
<p>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const panel = style({
|
||||
width: "100%",
|
||||
|
@ -11,6 +12,8 @@ export const panel = style({
|
|||
justifyContent: "space-between",
|
||||
transition: "all ease 0.2s",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
|
@ -29,3 +32,27 @@ export const downloadDetailsRow = style({
|
|||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
});
|
||||
|
||||
export const progressBar = recipe({
|
||||
base: {
|
||||
position: "absolute",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "3px",
|
||||
transition: "all ease 0.2s",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,105 +1,92 @@
|
|||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Color from "color";
|
||||
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
import type { Game, GameRepack } from "@types";
|
||||
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { HeroPanelActions } from "./hero-panel-actions";
|
||||
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
|
||||
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
game: Game | null;
|
||||
color: string;
|
||||
isGamePlaying: boolean;
|
||||
objectID: string;
|
||||
title: string;
|
||||
repacks: GameRepack[];
|
||||
openRepacksModal: () => void;
|
||||
getGame: () => void;
|
||||
}
|
||||
|
||||
export function HeroPanel({
|
||||
game,
|
||||
color,
|
||||
repacks,
|
||||
objectID,
|
||||
title,
|
||||
isGamePlaying,
|
||||
openRepacksModal,
|
||||
getGame,
|
||||
}: HeroPanelProps) {
|
||||
export function HeroPanel() {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const { game, repacks, gameColor } = useContext(gameDetailsContext);
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
progress,
|
||||
eta,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
gameDownloading?.id === game?.id &&
|
||||
GameStatusHelper.isDownloading(game?.status ?? null);
|
||||
const { progress, eta, lastPacket, isGameDeleting } = useDownload();
|
||||
|
||||
const finalDownloadSize = useMemo(() => {
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
||||
if (gameDownloading?.fileSize && isGameDownloading)
|
||||
return formatBytes(gameDownloading.fileSize);
|
||||
if (lastPacket?.game.fileSize && game?.status === "active")
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
}, [game, isGameDownloading, gameDownloading]);
|
||||
}, [game, lastPacket?.game]);
|
||||
|
||||
const isGameDownloading =
|
||||
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||
|
||||
const getInfo = () => {
|
||||
if (isGameDeleting(game?.id ?? -1)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
|
||||
|
||||
if (game?.progress === 1) return <HeroPanelPlaytime />;
|
||||
|
||||
if (game?.status === "active") {
|
||||
if (lastPacket?.isDownloadingMetadata && isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("downloading_metadata")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeDownloaded = formatBytes(
|
||||
lastPacket?.game?.bytesDownloaded ?? game?.bytesDownloaded
|
||||
);
|
||||
|
||||
const showPeers =
|
||||
game?.downloader === Downloader.Torrent &&
|
||||
lastPacket?.numPeers !== undefined;
|
||||
|
||||
if (isGameDownloading && gameDownloading?.status) {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{progress}
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
{isGameDownloading
|
||||
? progress
|
||||
: formatDownloadProgress(game?.progress)}
|
||||
|
||||
<small>{eta ? t("eta", { eta }) : t("calculating_eta")}</small>
|
||||
</p>
|
||||
|
||||
{gameDownloading.status !== GameStatus.Downloading ? (
|
||||
<>
|
||||
<p>{t(gameDownloading.status)}</p>
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
<span>
|
||||
{sizeDownloaded} / {finalDownloadSize}
|
||||
</span>
|
||||
{showPeers && (
|
||||
<small>
|
||||
{game?.downloader === Downloader.Torrent &&
|
||||
`${numPeers} peers / ${numSeeds} seeds`}
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
if (game?.status === "paused") {
|
||||
const formattedProgress = formatDownloadProgress(game.progress);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{t("paused_progress", {
|
||||
progress: formatDownloadProgress(game.progress),
|
||||
})}
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{formattedProgress} <small>{t("paused")}</small>
|
||||
</p>
|
||||
<p>
|
||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
|
@ -108,10 +95,6 @@ export function HeroPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (game && GameStatusHelper.isReady(game?.status ?? GameStatus.Finished)) {
|
||||
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
|
||||
}
|
||||
|
||||
const [latestRepack] = repacks;
|
||||
|
||||
if (latestRepack) {
|
||||
|
@ -129,28 +112,33 @@ export function HeroPanel({
|
|||
return <p>{t("no_downloads")}</p>;
|
||||
};
|
||||
|
||||
const backgroundColor = gameColor
|
||||
? (new Color(gameColor).darken(0.6).toString() as string)
|
||||
: "";
|
||||
|
||||
const showProgressBar =
|
||||
(game?.status === "active" && game?.progress < 1) ||
|
||||
game?.status === "paused";
|
||||
|
||||
return (
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
|
||||
<div style={{ backgroundColor: color }} className={styles.panel}>
|
||||
<div style={{ backgroundColor }} className={styles.panel}>
|
||||
<div className={styles.content}>{getInfo()}</div>
|
||||
<div className={styles.actions}>
|
||||
<HeroPanelActions
|
||||
game={game}
|
||||
repacks={repacks}
|
||||
objectID={objectID}
|
||||
title={title}
|
||||
getGame={getGame}
|
||||
openRepacksModal={openRepacksModal}
|
||||
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
|
||||
isGamePlaying={isGamePlaying}
|
||||
isGameDownloading={isGameDownloading}
|
||||
/>
|
||||
<HeroPanelActions />
|
||||
</div>
|
||||
|
||||
{showProgressBar && (
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
isGameDownloading ? lastPacket?.game.progress : game?.progress
|
||||
}
|
||||
className={styles.progressBar({
|
||||
disabled: game?.status === "paused",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const container = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
export const downloadsPathField = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const hintText = style({
|
||||
fontSize: "12px",
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
||||
export const downloaders = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloaderOption = style({
|
||||
flex: "1",
|
||||
position: "relative",
|
||||
});
|
||||
|
||||
export const downloaderIcon = style({
|
||||
position: "absolute",
|
||||
left: `${SPACING_UNIT * 2}px`,
|
||||
});
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DiskSpace } from "check-disk-space";
|
||||
import * as styles from "./download-settings-modal.css";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
|
||||
export interface DownloadSettingsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
|
||||
const downloaders = [Downloader.Torrent, Downloader.RealDebrid];
|
||||
|
||||
export function DownloadSettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
startDownload,
|
||||
repack,
|
||||
}: DownloadSettingsModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [selectedDownloader, setSelectedDownloader] = useState(
|
||||
Downloader.Torrent
|
||||
);
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
getDiskFreeSpace(selectedPath);
|
||||
}
|
||||
}, [visible, selectedPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences?.downloadsPath) {
|
||||
setSelectedPath(userPreferences.downloadsPath);
|
||||
} else {
|
||||
window.electron
|
||||
.getDefaultDownloadsPath()
|
||||
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
|
||||
}
|
||||
|
||||
if (userPreferences?.realDebridApiToken)
|
||||
setSelectedDownloader(Downloader.RealDebrid);
|
||||
}, [userPreferences?.downloadsPath, userPreferences?.realDebridApiToken]);
|
||||
|
||||
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 = () => {
|
||||
if (repack) {
|
||||
setDownloadStarting(true);
|
||||
|
||||
startDownload(repack, selectedDownloader, selectedPath).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("download_settings")}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{t("downloader")}
|
||||
</span>
|
||||
|
||||
<div className={styles.downloaders}>
|
||||
{downloaders.map((downloader) => (
|
||||
<Button
|
||||
key={downloader}
|
||||
className={styles.downloaderOption}
|
||||
theme={
|
||||
selectedDownloader === downloader ? "primary" : "outline"
|
||||
}
|
||||
disabled={
|
||||
downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
{selectedDownloader === downloader && (
|
||||
<CheckCircleFillIcon className={styles.downloaderIcon} />
|
||||
)}
|
||||
{DOWNLOADER_NAME[downloader]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
value={selectedPath}
|
||||
readOnly
|
||||
disabled
|
||||
label={t("download_path")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
disabled={downloadStarting}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className={styles.hintText}>
|
||||
<Trans i18nKey="select_folder_hint" ns="game_details">
|
||||
<Link to="/settings" />
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleStartClick} disabled={downloadStarting}>
|
||||
<DownloadIcon />
|
||||
{t("download_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
3
src/renderer/src/pages/game-details/modals/index.ts
Normal file
3
src/renderer/src/pages/game-details/modals/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./installation-guides";
|
||||
export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
|
@ -1,4 +1,4 @@
|
|||
import { vars } from "../../../theme.css";
|
||||
import { vars } from "../../../../theme.css";
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
export const slideIn = keyframes({
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Modal } from "@renderer/components";
|
||||
|
@ -7,18 +7,19 @@ import { SPACING_UNIT } from "@renderer/theme.css";
|
|||
import * as styles from "./dodi-installation-guide.css";
|
||||
import { ArrowUpIcon } from "@primer/octicons-react";
|
||||
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
|
||||
import { gameDetailsContext } from "../../game-details.context";
|
||||
|
||||
export interface DODIInstallationGuideProps {
|
||||
windowColor: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DODIInstallationGuide({
|
||||
windowColor,
|
||||
visible,
|
||||
onClose,
|
||||
}: DODIInstallationGuideProps) {
|
||||
const { gameColor } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
@ -53,7 +54,7 @@ export function DODIInstallationGuide({
|
|||
|
||||
<div
|
||||
className={styles.windowContainer}
|
||||
style={{ backgroundColor: windowColor }}
|
||||
style={{ backgroundColor: gameColor }}
|
||||
>
|
||||
<div className={styles.windowContent}>
|
||||
<ArrowUpIcon size={24} />
|
|
@ -1,4 +1,4 @@
|
|||
import { SPACING_UNIT } from "../../../theme.css";
|
||||
import { SPACING_UNIT } from "../../../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const passwordField = style({
|
|
@ -1,5 +1,5 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const repacks = style({
|
||||
display: "flex",
|
||||
|
@ -13,6 +13,6 @@ export const repackButton = style({
|
|||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
|
@ -6,20 +6,24 @@ import type { GameRepack } from "@types";
|
|||
|
||||
import * as styles from "./repacks-modal.css";
|
||||
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
import { SPACING_UNIT } from "../../../theme.css";
|
||||
import { format } from "date-fns";
|
||||
import { SelectFolderModal } from "./select-folder-modal";
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
repacks: GameRepack[];
|
||||
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RepacksModal({
|
||||
visible,
|
||||
repacks,
|
||||
startDownload,
|
||||
onClose,
|
||||
}: RepacksModalProps) {
|
||||
|
@ -27,6 +31,8 @@ export function RepacksModal({
|
|||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
|
||||
const { repacks } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -55,7 +61,7 @@ export function RepacksModal({
|
|||
|
||||
return (
|
||||
<>
|
||||
<SelectFolderModal
|
||||
<DownloadSettingsModal
|
||||
visible={showSelectFolderModal}
|
||||
onClose={() => setShowSelectFolderModal(false)}
|
||||
startDownload={startDownload}
|
|
@ -1,19 +0,0 @@
|
|||
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}px`,
|
||||
});
|
||||
|
||||
export const hintText = style({
|
||||
fontSize: "12px",
|
||||
color: vars.color.bodyText,
|
||||
});
|
|
@ -1,106 +0,0 @@
|
|||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import type { GameRepack } from "@types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DiskSpace } from "check-disk-space";
|
||||
import * as styles from "./select-folder-modal.css";
|
||||
import { DownloadIcon } from "@primer/octicons-react";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
export interface SelectFolderModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
|
||||
export function SelectFolderModal({
|
||||
visible,
|
||||
onClose,
|
||||
startDownload,
|
||||
repack,
|
||||
}: SelectFolderModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(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 = () => {
|
||||
if (repack) {
|
||||
setDownloadStarting(true);
|
||||
|
||||
startDownload(repack, selectedPath).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("download_path")}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField value={selectedPath} readOnly disabled />
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
disabled={downloadStarting}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={styles.hintText}>
|
||||
<Trans i18nKey="select_folder_hint" ns="game_details">
|
||||
<Link to="/settings" />
|
||||
</Trans>
|
||||
</p>
|
||||
<Button onClick={handleStartClick} disabled={downloadStarting}>
|
||||
<DownloadIcon />
|
||||
{t("download_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -88,5 +88,5 @@ export const howLongToBeatCategorySkeleton = style({
|
|||
|
||||
globalStyle(`${requirementsDetails} a`, {
|
||||
display: "flex",
|
||||
color: vars.color.bodyText,
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
|
|
@ -1,22 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import type {
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
SteamAppDetails,
|
||||
} from "@types";
|
||||
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@renderer/components";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
|
||||
export interface SidebarProps {
|
||||
objectID: string;
|
||||
title: string;
|
||||
gameDetails: ShopDetails | null;
|
||||
}
|
||||
|
||||
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
||||
export function Sidebar() {
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||
isLoading: boolean;
|
||||
data: HowLongToBeatCategory[] | null;
|
||||
|
@ -25,20 +16,24 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
|||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
useEffect(() => {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
if (objectID) {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
|
||||
window.electron
|
||||
.getHowLongToBeat(objectID, "steam", title)
|
||||
.then((howLongToBeat) => {
|
||||
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
||||
})
|
||||
.catch(() => {
|
||||
setHowLongToBeat({ isLoading: false, data: null });
|
||||
});
|
||||
}, [objectID, title]);
|
||||
window.electron
|
||||
.getHowLongToBeat(objectID, "steam", gameTitle)
|
||||
.then((howLongToBeat) => {
|
||||
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
||||
})
|
||||
.catch(() => {
|
||||
setHowLongToBeat({ isLoading: false, data: null });
|
||||
});
|
||||
}
|
||||
}, [objectID, gameTitle]);
|
||||
|
||||
return (
|
||||
<aside className={styles.contentSidebar}>
|
||||
|
@ -73,9 +68,9 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
|||
className={styles.requirementsDetails}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
gameDetails?.pc_requirements?.[activeRequirement] ??
|
||||
shopDetails?.pc_requirements?.[activeRequirement] ??
|
||||
t(`no_${activeRequirement}_requirements`, {
|
||||
title,
|
||||
gameTitle,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -4,16 +4,19 @@ import { useTranslation } from "react-i18next";
|
|||
import type { UserPreferences } from "@types";
|
||||
|
||||
import { CheckboxField } from "@renderer/components";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
|
||||
export interface SettingsBehaviorProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsBehavior({
|
||||
updateUserPreferences,
|
||||
userPreferences,
|
||||
}: SettingsBehaviorProps) {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
preferQuitInsteadOfHiding: false,
|
||||
runAtStartup: false,
|
||||
|
|
|
@ -5,6 +5,7 @@ import { TextField, Button, CheckboxField, Select } from "@renderer/components";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./settings-general.css";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
|
||||
import { changeLanguage } from "i18next";
|
||||
import * as languageResources from "@locales";
|
||||
|
@ -15,16 +16,18 @@ interface LanguageOption {
|
|||
}
|
||||
|
||||
export interface SettingsGeneralProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsGeneral({
|
||||
userPreferences,
|
||||
updateUserPreferences,
|
||||
}: SettingsGeneralProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
|
|
|
@ -7,3 +7,8 @@ export const form = style({
|
|||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
fontFamily: "'Fira Sans', sans-serif",
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
|
|
@ -5,23 +5,29 @@ import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
|||
import * as styles from "./settings-real-debrid.css";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
|
||||
|
||||
export interface SettingsRealDebridProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsRealDebrid({
|
||||
userPreferences,
|
||||
updateUserPreferences,
|
||||
}: SettingsRealDebridProps) {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useRealDebrid: false,
|
||||
realDebridApiToken: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -33,17 +39,50 @@ export function SettingsRealDebrid({
|
|||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
updateUserPreferences({
|
||||
realDebridApiToken: form.useRealDebrid ? form.realDebridApiToken : null,
|
||||
});
|
||||
|
||||
try {
|
||||
if (form.useRealDebrid) {
|
||||
const user = await window.electron.authenticateRealDebrid(
|
||||
form.realDebridApiToken!
|
||||
);
|
||||
|
||||
if (user.type === "free") {
|
||||
showErrorToast(
|
||||
t("real_debrid_free_account_error", { username: user.username })
|
||||
);
|
||||
|
||||
return;
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("real_debrid_linked_message", { username: user.username })
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
realDebridApiToken: form.useRealDebrid ? form.realDebridApiToken : null,
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(t("real_debrid_invalid_token"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled = form.useRealDebrid && !form.realDebridApiToken;
|
||||
const isButtonDisabled =
|
||||
(form.useRealDebrid && !form.realDebridApiToken) || isLoading;
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleFormSubmit}>
|
||||
<p className={styles.description}>{t("real_debrid_description")}</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_real_debrid")}
|
||||
checked={form.useRealDebrid}
|
||||
|
@ -57,7 +96,7 @@ export function SettingsRealDebrid({
|
|||
|
||||
{form.useRealDebrid && (
|
||||
<TextField
|
||||
label={t("real_debrid_api_token_label")}
|
||||
label={t("real_debrid_api_token")}
|
||||
value={form.realDebridApiToken ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
|
@ -75,7 +114,7 @@ export function SettingsRealDebrid({
|
|||
|
||||
<Button
|
||||
type="submit"
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
style={{ alignSelf: "flex-end", marginTop: `${SPACING_UNIT * 2}px` }}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{t("save_changes")}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@renderer/components";
|
||||
|
||||
import * as styles from "./settings.css";
|
||||
|
@ -7,70 +7,61 @@ import { UserPreferences } from "@types";
|
|||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
|
||||
const categories = ["general", "behavior", "real_debrid"];
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { setUserPreferences } from "@renderer/features";
|
||||
|
||||
export function Settings() {
|
||||
const [currentCategory, setCurrentCategory] = useState(categories.at(0)!);
|
||||
const [userPreferences, setUserPreferences] =
|
||||
useState<UserPreferences | null>(null);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getUserPreferences().then((userPreferences) => {
|
||||
setUserPreferences(userPreferences);
|
||||
});
|
||||
}, []);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleUpdateUserPreferences = (values: Partial<UserPreferences>) => {
|
||||
window.electron.updateUserPreferences(values);
|
||||
const categories = [t("general"), t("behavior"), "Real-Debrid"];
|
||||
|
||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||
|
||||
const handleUpdateUserPreferences = async (
|
||||
values: Partial<UserPreferences>
|
||||
) => {
|
||||
await window.electron.updateUserPreferences(values);
|
||||
window.electron.getUserPreferences().then((userPreferences) => {
|
||||
dispatch(setUserPreferences(userPreferences));
|
||||
});
|
||||
};
|
||||
|
||||
function renderCategory() {
|
||||
switch (currentCategory) {
|
||||
case "general":
|
||||
return (
|
||||
<SettingsGeneral
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
case "real_debrid":
|
||||
return (
|
||||
<SettingsRealDebrid
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
case "behavior":
|
||||
return (
|
||||
<SettingsBehavior
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
const renderCategory = () => {
|
||||
if (currentCategoryIndex === 0) {
|
||||
return (
|
||||
<SettingsGeneral updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 1) {
|
||||
return (
|
||||
<SettingsBehavior updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<section className={styles.settingsCategories}>
|
||||
{categories.map((category) => (
|
||||
{categories.map((category, index) => (
|
||||
<Button
|
||||
key={category}
|
||||
theme={currentCategory === category ? "primary" : "outline"}
|
||||
onClick={() => setCurrentCategory(category)}
|
||||
theme={currentCategoryIndex === index ? "primary" : "outline"}
|
||||
onClick={() => setCurrentCategoryIndex(index)}
|
||||
>
|
||||
{t(category)}
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<h2>{t(currentCategory)}</h2>
|
||||
<h2>{categories[currentCategoryIndex]}</h2>
|
||||
{renderCategory()}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const main = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: `${SPACING_UNIT * 3}px`,
|
||||
flex: "1",
|
||||
overflowY: "auto",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const splashIcon = style({
|
||||
width: "75%",
|
||||
});
|
||||
|
||||
export const updateInfoSection = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
overflowY: "auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
export const progressBar = style({
|
||||
WebkitAppearance: "none",
|
||||
appearance: "none",
|
||||
borderRadius: "4px",
|
||||
width: "100%",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
height: "18px",
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
transition: "width 0.2s",
|
||||
},
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
},
|
||||
});
|
||||
|
||||
export const progressBarText = style({
|
||||
zIndex: 2,
|
||||
});
|
|
@ -1,82 +0,0 @@
|
|||
import icon from "@renderer/assets/icon.png";
|
||||
import * as styles from "./splash.css";
|
||||
import { themeClass } from "../../theme.css";
|
||||
|
||||
import "../../app.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppUpdaterEvents } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
document.body.classList.add(themeClass);
|
||||
|
||||
export default function Splash() {
|
||||
const [status, setStatus] = useState<AppUpdaterEvents | null>(null);
|
||||
const [newVersion, setNewVersion] = useState("");
|
||||
|
||||
const { t } = useTranslation("splash");
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAutoUpdaterEvent(
|
||||
(event: AppUpdaterEvents) => {
|
||||
setStatus(event);
|
||||
|
||||
switch (event.type) {
|
||||
case "error":
|
||||
window.electron.continueToMainWindow();
|
||||
break;
|
||||
case "update-available":
|
||||
setNewVersion(event.info.version);
|
||||
break;
|
||||
case "update-cancelled":
|
||||
window.electron.continueToMainWindow();
|
||||
break;
|
||||
case "update-downloaded":
|
||||
window.electron.restartAndInstallUpdate();
|
||||
break;
|
||||
case "update-not-available":
|
||||
window.electron.continueToMainWindow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
window.electron.checkForUpdates();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderUpdateInfo = () => {
|
||||
switch (status?.type) {
|
||||
case "download-progress":
|
||||
return (
|
||||
<>
|
||||
<p>{t("downloading_version", { version: newVersion })}</p>
|
||||
<progress
|
||||
className={styles.progressBar}
|
||||
max="100"
|
||||
value={status.info.percent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "checking-for-updates":
|
||||
return <p>{t("searching_updates")}</p>;
|
||||
case "update-available":
|
||||
return <p>{t("update_found", { version: newVersion })}</p>;
|
||||
case "update-downloaded":
|
||||
return <p>{t("restarting_and_applying")}</p>;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<img src={icon} className={styles.splashIcon} alt="Hydra Launcher Logo" />
|
||||
<section className={styles.updateInfoSection}>
|
||||
{renderUpdateInfo()}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
|
@ -5,6 +5,7 @@ import {
|
|||
librarySlice,
|
||||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
export const store = configureStore({
|
||||
|
@ -14,6 +15,7 @@ export const store = configureStore({
|
|||
library: librarySlice.reducer,
|
||||
userPreferences: userPreferencesSlice.reducer,
|
||||
download: downloadSlice.reducer,
|
||||
toast: toastSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -7,14 +7,16 @@ export const [themeClass, vars] = createTheme({
|
|||
background: "#1c1c1c",
|
||||
darkBackground: "#151515",
|
||||
muted: "#c0c1c7",
|
||||
bodyText: "#8e919b",
|
||||
body: "#8e919b",
|
||||
border: "#424244",
|
||||
success: "#1c9749",
|
||||
danger: "#e11d48",
|
||||
},
|
||||
opacity: {
|
||||
disabled: "0.5",
|
||||
active: "0.7",
|
||||
},
|
||||
size: {
|
||||
bodyFontSize: "14px",
|
||||
body: "14px",
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue