Merge branch 'main' into teste-locale2

This commit is contained in:
Zamitto 2024-05-29 22:25:46 -03:00 committed by GitHub
commit 37b5cb6b60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 2440 additions and 2549 deletions

View file

@ -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%",

View file

@ -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}
/>
</>
);
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`,
},
},
},
});

View file

@ -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>
);
}

View file

@ -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",
},
});

View file

@ -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}

View file

@ -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: [

View file

@ -109,6 +109,6 @@ export const shopIcon = style({
});
export const noDownloadsLabel = style({
color: vars.color.bodyText,
color: vars.color.body,
fontWeight: "bold",
});

View file

@ -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} />,
};

View file

@ -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",
},
});

View file

@ -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>
)}
</>
);
}

View file

@ -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";

View file

@ -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,
});

View file

@ -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",

View file

@ -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

View 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,
});

View 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>
);
}

View file

@ -1 +1,8 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Exodus";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
[Downloader.Torrent]: "Torrent",
};

View file

@ -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 {

View file

@ -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;
},

View file

@ -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";

View 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;

View file

@ -1,4 +1,5 @@
export * from "./use-download";
export * from "./use-library";
export * from "./use-date";
export * from "./use-toast";
export * from "./redux";

View file

@ -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)),
};
}

View 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 };
}

View file

@ -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} />

View file

@ -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 = () => {

View file

@ -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({

View file

@ -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>

View file

@ -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>
);

View file

@ -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({

View file

@ -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}

View 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>
);
}

View file

@ -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,
});

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>

View file

@ -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,
},
},
},
});

View file

@ -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>
</>
);

View file

@ -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`,
});

View file

@ -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>
);
}

View file

@ -0,0 +1,3 @@
export * from "./installation-guides";
export * from "./repacks-modal";
export * from "./download-settings-modal";

View file

@ -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({

View file

@ -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} />

View file

@ -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({

View file

@ -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`,
});

View file

@ -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}

View file

@ -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,
});

View file

@ -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>
);
}

View file

@ -88,5 +88,5 @@ export const howLongToBeatCategorySkeleton = style({
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
color: vars.color.body,
});

View file

@ -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,
}),
}}
/>

View file

@ -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,

View file

@ -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,

View file

@ -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`,
});

View file

@ -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")}

View file

@ -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>

View file

@ -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,
});

View file

@ -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>
);
}

View file

@ -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,
},
});

View file

@ -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",
},
});