mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'main' into build/win-portable-release
# Conflicts: # .github/workflows/build.yml
This commit is contained in:
commit
bc82cf2539
121 changed files with 2883 additions and 2219 deletions
12
src/renderer/src/components/badge/badge.css.ts
Normal file
12
src/renderer/src/components/badge/badge.css.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
|
||||
export const badge = style({
|
||||
color: "#c0c1c7",
|
||||
fontSize: "10px",
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
||||
border: "solid 1px #c0c1c7",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
});
|
14
src/renderer/src/components/badge/badge.tsx
Normal file
14
src/renderer/src/components/badge/badge.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from "react";
|
||||
import * as styles from "./badge.css";
|
||||
|
||||
export interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ children }: BadgeProps) {
|
||||
return (
|
||||
<div className={styles.badge}>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -55,4 +55,15 @@ export const button = styleVariants({
|
|||
color: "#c0c1c7",
|
||||
},
|
||||
],
|
||||
danger: [
|
||||
base,
|
||||
{
|
||||
border: `solid 1px #a31533`,
|
||||
backgroundColor: "transparent",
|
||||
color: "white",
|
||||
":hover": {
|
||||
backgroundColor: "#a31533",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -69,16 +69,7 @@ export const downloadOptions = style({
|
|||
padding: "0",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexWrap: "wrap",
|
||||
});
|
||||
|
||||
export const downloadOption = style({
|
||||
color: "#c0c1c7",
|
||||
fontSize: "10px",
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
||||
border: "solid 1px #c0c1c7",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
listStyle: "none",
|
||||
});
|
||||
|
||||
export const specifics = style({
|
||||
|
|
|
@ -5,6 +5,7 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
|||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
|
@ -39,8 +40,8 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||
{uniqueRepackers.length > 0 ? (
|
||||
<ul className={styles.downloadOptions}>
|
||||
{uniqueRepackers.map((repacker) => (
|
||||
<li key={repacker} className={styles.downloadOption}>
|
||||
<span>{repacker}</span>
|
||||
<li key={repacker}>
|
||||
<Badge>{repacker}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -71,7 +71,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
isWindows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
<section className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton({
|
||||
|
@ -90,7 +90,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
|
|
|
@ -23,6 +23,7 @@ export const heroMedia = style({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
transition: "all ease 0.2s",
|
||||
imageRendering: "revert",
|
||||
selectors: {
|
||||
[`${hero}:hover &`]: {
|
||||
transform: "scale(1.02)",
|
||||
|
|
|
@ -54,7 +54,7 @@ export function Hero() {
|
|||
>
|
||||
<div className={styles.backdrop}>
|
||||
<img
|
||||
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
|
||||
src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
|
||||
alt={FEATURED_GAME_TITLE}
|
||||
className={styles.heroMedia}
|
||||
/>
|
||||
|
|
|
@ -8,5 +8,6 @@ export * from "./sidebar/sidebar";
|
|||
export * from "./text-field/text-field";
|
||||
export * from "./checkbox-field/checkbox-field";
|
||||
export * from "./link/link";
|
||||
export * from "./select/select";
|
||||
export * from "./select-field/select-field";
|
||||
export * from "./toast/toast";
|
||||
export * from "./badge/badge";
|
||||
|
|
|
@ -2,26 +2,27 @@ import { keyframes, style } from "@vanilla-extract/css";
|
|||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const fadeIn = keyframes({
|
||||
"0%": { opacity: 0 },
|
||||
export const scaleFadeIn = keyframes({
|
||||
"0%": { opacity: "0", scale: "0.5" },
|
||||
"100%": {
|
||||
opacity: 1,
|
||||
opacity: "1",
|
||||
scale: "1",
|
||||
},
|
||||
});
|
||||
|
||||
export const fadeOut = keyframes({
|
||||
"0%": { opacity: 1 },
|
||||
export const scaleFadeOut = keyframes({
|
||||
"0%": { opacity: "1", scale: "1" },
|
||||
"100%": {
|
||||
opacity: 0,
|
||||
opacity: "0",
|
||||
scale: "0.5",
|
||||
},
|
||||
});
|
||||
|
||||
export const modal = recipe({
|
||||
base: {
|
||||
animationName: fadeIn,
|
||||
animationDuration: "0.3s",
|
||||
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "5px",
|
||||
borderRadius: "4px",
|
||||
maxWidth: "600px",
|
||||
color: vars.color.body,
|
||||
maxHeight: "100%",
|
||||
|
@ -33,8 +34,14 @@ export const modal = recipe({
|
|||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: fadeOut,
|
||||
opacity: 0,
|
||||
animationName: scaleFadeOut,
|
||||
opacity: "0",
|
||||
},
|
||||
},
|
||||
large: {
|
||||
true: {
|
||||
width: "800px",
|
||||
maxWidth: "800px",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -12,6 +12,7 @@ export interface ModalProps {
|
|||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
large?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
@ -20,6 +21,7 @@ export function Modal({
|
|||
title,
|
||||
description,
|
||||
onClose,
|
||||
large,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
@ -88,7 +90,7 @@ export function Modal({
|
|||
return createPortal(
|
||||
<Backdrop isClosing={isClosing}>
|
||||
<div
|
||||
className={styles.modal({ closing: isClosing })}
|
||||
className={styles.modal({ closing: isClosing, large })}
|
||||
role="dialog"
|
||||
aria-labelledby={title}
|
||||
aria-describedby={description}
|
||||
|
|
|
@ -49,9 +49,6 @@ export const option = style({
|
|||
fontSize: vars.size.body,
|
||||
textOverflow: "ellipsis",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
":focus": {
|
||||
cursor: "text",
|
||||
},
|
||||
});
|
||||
|
||||
export const label = style({
|
|
@ -1,6 +1,6 @@
|
|||
import { useId, useState } from "react";
|
||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import * as styles from "./select.css";
|
||||
import * as styles from "./select-field.css";
|
||||
|
||||
export interface SelectProps
|
||||
extends React.DetailedHTMLProps<
|
||||
|
@ -12,7 +12,7 @@ export interface SelectProps
|
|||
options?: { key: string; value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function Select({
|
||||
export function SelectField({
|
||||
value,
|
||||
label,
|
||||
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],
|
|
@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Game } from "@types";
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
import { TextField } from "@renderer/components";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
|
||||
import { routes } from "./routes";
|
||||
|
||||
|
@ -25,7 +25,7 @@ export function Sidebar() {
|
|||
const { library, updateLibrary } = useLibrary();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(
|
||||
|
@ -36,6 +36,8 @@ export function Sidebar() {
|
|||
|
||||
const { lastPacket, progress } = useDownload();
|
||||
|
||||
const { showWarningToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.game.id, updateLibrary]);
|
||||
|
@ -99,9 +101,7 @@ export function Sidebar() {
|
|||
};
|
||||
}, [isResizing]);
|
||||
|
||||
const getGameTitle = (game: Game) => {
|
||||
if (game.status === "paused") return t("paused", { title: game.title });
|
||||
|
||||
const getGameTitle = (game: LibraryGame) => {
|
||||
if (lastPacket?.game.id === game.id) {
|
||||
return t("downloading", {
|
||||
title: game.title,
|
||||
|
@ -109,6 +109,12 @@ export function Sidebar() {
|
|||
});
|
||||
}
|
||||
|
||||
if (game.downloadQueue !== null) {
|
||||
return t("queued", { title: game.title });
|
||||
}
|
||||
|
||||
if (game.status === "paused") return t("paused", { title: game.title });
|
||||
|
||||
return game.title;
|
||||
};
|
||||
|
||||
|
@ -118,6 +124,24 @@ export function Sidebar() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSidebarGameClick = (
|
||||
event: React.MouseEvent,
|
||||
game: LibraryGame
|
||||
) => {
|
||||
const path = buildGameDetailsPath(game);
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
if (event.detail == 2) {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
} else {
|
||||
showWarningToast(t("game_has_no_executable"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
|
@ -179,9 +203,7 @@ export function Sidebar() {
|
|||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() =>
|
||||
handleSidebarItemClick(buildGameDetailsPath(game))
|
||||
}
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
|
|
|
@ -42,18 +42,33 @@ export const textField = recipe({
|
|||
},
|
||||
});
|
||||
|
||||
export const textFieldInput = style({
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
outline: "none",
|
||||
color: "#DADBE1",
|
||||
cursor: "default",
|
||||
fontFamily: "inherit",
|
||||
textOverflow: "ellipsis",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
":focus": {
|
||||
cursor: "text",
|
||||
export const textFieldInput = recipe({
|
||||
base: {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
outline: "none",
|
||||
color: "#DADBE1",
|
||||
cursor: "default",
|
||||
fontFamily: "inherit",
|
||||
textOverflow: "ellipsis",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
":focus": {
|
||||
cursor: "text",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
readOnly: {
|
||||
true: {
|
||||
textOverflow: "inherit",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const togglePasswordButton = style({
|
||||
cursor: "pointer",
|
||||
color: vars.color.muted,
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { useId, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import * as styles from "./text-field.css";
|
||||
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TextFieldProps
|
||||
extends React.DetailedHTMLProps<
|
||||
|
@ -28,9 +30,20 @@ export function TextField({
|
|||
containerProps,
|
||||
...props
|
||||
}: TextFieldProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const { t } = useTranslation("forms");
|
||||
|
||||
const showPasswordToggleButton = props.type === "password";
|
||||
|
||||
const inputType = useMemo(() => {
|
||||
if (props.type === "password" && isPasswordVisible) return "text";
|
||||
return props.type ?? "text";
|
||||
}, [props.type, isPasswordVisible]);
|
||||
|
||||
return (
|
||||
<div className={styles.textFieldContainer} {...containerProps}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
|
@ -41,12 +54,27 @@ export function TextField({
|
|||
>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.textFieldInput}
|
||||
className={styles.textFieldInput({ readOnly: props.readOnly })}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
type={inputType}
|
||||
/>
|
||||
|
||||
{showPasswordToggleButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePasswordButton}
|
||||
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||
aria-label={t("toggle_password_visibility")}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeClosedIcon size={16} />
|
||||
) : (
|
||||
<EyeIcon size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hint && <small>{hint}</small>}
|
||||
|
|
|
@ -81,3 +81,7 @@ export const successIcon = style({
|
|||
export const errorIcon = style({
|
||||
color: vars.color.danger,
|
||||
});
|
||||
|
||||
export const warningIcon = style({
|
||||
color: vars.color.warning,
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertIcon,
|
||||
CheckCircleFillIcon,
|
||||
XCircleFillIcon,
|
||||
XIcon,
|
||||
|
@ -11,7 +12,7 @@ import { SPACING_UNIT } from "@renderer/theme.css";
|
|||
export interface ToastProps {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
type: "success" | "error" | "warning";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
@ -84,6 +85,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
|||
)}
|
||||
|
||||
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
|
||||
|
||||
{type === "warning" && <AlertIcon className={styles.warningIcon} />}
|
||||
<span style={{ fontWeight: "bold" }}>{message}</span>
|
||||
</div>
|
||||
|
||||
|
|
23
src/renderer/src/declaration.d.ts
vendored
23
src/renderer/src/declaration.d.ts
vendored
|
@ -1,8 +1,8 @@
|
|||
import type {
|
||||
AppUpdaterEvent,
|
||||
CatalogueCategory,
|
||||
CatalogueEntry,
|
||||
Game,
|
||||
LibraryGame,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
|
@ -12,6 +12,7 @@ import type {
|
|||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -33,7 +34,7 @@ declare global {
|
|||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: () => Promise<CatalogueEntry[]>;
|
||||
getGameShopDetails: (
|
||||
objectID: string,
|
||||
shop: GameShop,
|
||||
|
@ -55,11 +56,14 @@ declare global {
|
|||
addGameToLibrary: (
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
executablePath: string | null
|
||||
shop: GameShop
|
||||
) => Promise<void>;
|
||||
getLibrary: () => Promise<Game[]>;
|
||||
createGameShortcut: (id: number) => Promise<boolean>;
|
||||
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
|
||||
getLibrary: () => Promise<LibraryGame[]>;
|
||||
openGameInstaller: (gameId: number) => Promise<boolean>;
|
||||
openGameInstallerPath: (gameId: number) => Promise<boolean>;
|
||||
openGameExecutablePath: (gameId: number) => Promise<void>;
|
||||
openGame: (gameId: number, executablePath: string) => Promise<void>;
|
||||
closeGame: (gameId: number) => Promise<boolean>;
|
||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||
|
@ -77,6 +81,15 @@ declare global {
|
|||
autoLaunch: (enabled: boolean) => Promise<void>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
validateDownloadSource: (
|
||||
url: string
|
||||
) => Promise<{ name: string; downloadCount: number }>;
|
||||
addDownloadSource: (url: string) => Promise<DownloadSource>;
|
||||
removeDownloadSource: (id: number) => Promise<void>;
|
||||
syncDownloadSources: () => Promise<void>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { Game } from "@types";
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
export interface LibraryState {
|
||||
value: Game[];
|
||||
value: LibraryGame[];
|
||||
}
|
||||
|
||||
const initialState: LibraryState = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { CatalogueEntry } from "@types";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
export const steamUrlBuilder = {
|
||||
library: (objectID: string) =>
|
||||
|
@ -34,7 +34,7 @@ export const getSteamLanguage = (language: string) => {
|
|||
};
|
||||
|
||||
export const buildGameDetailsPath = (
|
||||
game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
|
||||
game: { shop: GameShop; objectID: string; title: string },
|
||||
params: Record<string, string> = {}
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({ title: game.title, ...params });
|
||||
|
|
|
@ -41,24 +41,25 @@ export function useDownload() {
|
|||
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();
|
||||
} finally {
|
||||
dispatch(removeGameFromDeleting(gameId));
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDownload = async (gameId: number) => {
|
||||
await window.electron.cancelGameDownload(gameId);
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
removeGameInstaller(gameId);
|
||||
};
|
||||
|
||||
const removeGameFromLibrary = (gameId: number) =>
|
||||
window.electron.removeGameFromLibrary(gameId).then(() => {
|
||||
updateLibrary();
|
||||
|
|
|
@ -29,5 +29,17 @@ export function useToast() {
|
|||
[dispatch]
|
||||
);
|
||||
|
||||
return { showSuccessToast, showErrorToast };
|
||||
const showWarningToast = useCallback(
|
||||
(message: string) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
message,
|
||||
type: "warning",
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return { showSuccessToast, showErrorToast, showWarningToast };
|
||||
}
|
||||
|
|
107
src/renderer/src/pages/downloads/download-group.css.ts
Normal file
107
src/renderer/src/pages/downloads/download-group.css.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const downloadTitleWrapper = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadTitle = style({
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
textAlign: "left",
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
width: "100%",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadCover = style({
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
height: "auto",
|
||||
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 = style({
|
||||
width: "100%",
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
borderRadius: "8px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
transition: "all ease 0.2s",
|
||||
height: "140px",
|
||||
minHeight: "140px",
|
||||
maxHeight: "140px",
|
||||
});
|
||||
|
||||
export const downloadDetails = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "1",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
fontSize: "14px",
|
||||
});
|
||||
|
||||
export const downloadRightContent = style({
|
||||
display: "flex",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadActions = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadGroup = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
237
src/renderer/src/pages/downloads/download-group.tsx
Normal file
237
src/renderer/src/pages/downloads/download-group.tsx
Normal file
|
@ -0,0 +1,237 @@
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
steamUrlBuilder,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useDownload } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./download-group.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
openDeleteGameModal: (gameId: number) => void;
|
||||
openGameInstaller: (gameId: number) => void;
|
||||
}
|
||||
|
||||
export function DownloadGroup({
|
||||
library,
|
||||
title,
|
||||
openDeleteGameModal,
|
||||
openGameInstaller,
|
||||
}: DownloadGroupProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const {
|
||||
lastPacket,
|
||||
progress,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
||||
if (lastPacket?.game.fileSize && isGameDownloading)
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
const getGameInfo = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.progress === 1) {
|
||||
return <p>{t("completed")}</p>;
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
<p>{t(game.downloadQueue && lastPacket ? "queued" : "paused")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t(game.status)}</p>;
|
||||
};
|
||||
|
||||
const getGameActions = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openGameInstaller(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (!library.length) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.downloadGroup}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h2>{title}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>{library.length}</h3>
|
||||
</div>
|
||||
|
||||
<ul className={styles.downloads}>
|
||||
{library.map((game) => {
|
||||
return (
|
||||
<li key={game.id} className={styles.download}>
|
||||
<div className={styles.downloadCover}>
|
||||
<div className={styles.downloadCoverBackdrop}>
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectID)}
|
||||
className={styles.downloadCoverImage}
|
||||
alt={game.title}
|
||||
/>
|
||||
|
||||
<div className={styles.downloadCoverContent}>
|
||||
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.downloadRightContent}>
|
||||
<div className={styles.downloadDetails}>
|
||||
<div className={styles.downloadTitleWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadActions}>
|
||||
{getGameActions(game)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,134 +1,5 @@
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const downloadTitleWrapper = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadTitle = style({
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
textAlign: "left",
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
export const repackTitle = style({
|
||||
maxHeight: "40px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const downloaderName = style({
|
||||
color: "#c0c1c7",
|
||||
fontSize: "10px",
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
||||
border: "solid 1px #c0c1c7",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
width: "100%",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
marginTop: `${SPACING_UNIT * 3}px`,
|
||||
});
|
||||
|
||||
export const downloadCover = style({
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
height: "auto",
|
||||
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({
|
||||
base: {
|
||||
width: "100%",
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
borderRadius: "8px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
transition: "all ease 0.2s",
|
||||
height: "140px",
|
||||
minHeight: "140px",
|
||||
maxHeight: "140px",
|
||||
},
|
||||
variants: {
|
||||
cancelled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
":hover": {
|
||||
opacity: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDetails = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "1",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
fontSize: "14px",
|
||||
});
|
||||
|
||||
export const downloadRightContent = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadActions = style({
|
||||
height: "fit-content",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadsContainer = style({
|
||||
display: "flex",
|
||||
|
@ -136,3 +7,30 @@ export const downloadsContainer = style({
|
|||
flexDirection: "column",
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
export const downloadGroups = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const arrowIcon = style({
|
||||
width: "60px",
|
||||
height: "60px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const noDownloads = style({
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
|
|
@ -1,235 +1,106 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button, TextField } from "@renderer/components";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
steamUrlBuilder,
|
||||
} from "@renderer/helpers";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
import type { Game } from "@types";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import { LibraryGame } from "@types";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
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);
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const {
|
||||
lastPacket,
|
||||
progress,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
removeGameFromLibrary,
|
||||
cancelDownload,
|
||||
removeGameInstaller,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
const libraryWithDownloadedGamesOnly = useMemo(() => {
|
||||
return library.filter((game) => game.status);
|
||||
}, [library]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLibrary(libraryWithDownloadedGamesOnly);
|
||||
}, [libraryWithDownloadedGamesOnly]);
|
||||
|
||||
const openGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const getFinalDownloadSize = (game: Game) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
||||
if (lastPacket?.game.fileSize && isGameDownloading)
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
};
|
||||
|
||||
const getGameInfo = (game: Game) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.repackTitle}>{game.repack?.title}</p>
|
||||
<p>{t("completed")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
<p>{t("paused")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
gameToBeDeleted.current = gameId;
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const getGameActions = (game: Game) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openGameInstaller(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => openDeleteModal(game.id)} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<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(buildGameDetailsPath(game))}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("download_again")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => removeGameFromLibrary(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("remove_from_list")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setFilteredLibrary(
|
||||
libraryWithDownloadedGamesOnly.filter((game) =>
|
||||
game.title
|
||||
.toLowerCase()
|
||||
.includes(event.target.value.toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
const { removeGameInstaller } = useDownload();
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
if (gameToBeDeleted.current)
|
||||
await removeGameInstaller(gameToBeDeleted.current);
|
||||
};
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const handleOpenGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const handleOpenDeleteGameModal = (gameId: number) => {
|
||||
gameToBeDeleted.current = gameId;
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const libraryGroup: Record<string, LibraryGame[]> = useMemo(() => {
|
||||
const initialValue: Record<string, LibraryGame[]> = {
|
||||
downloading: [],
|
||||
queued: [],
|
||||
complete: [],
|
||||
};
|
||||
|
||||
const result = library.reduce((prev, next) => {
|
||||
/* Game has been manually added to the library or has been canceled */
|
||||
if (!next.status || next.status === "removed") return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.game.id === next.id)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
if (next.downloadQueue || next.status === "paused")
|
||||
return { ...prev, queued: [...prev.queued, next] };
|
||||
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
}, initialValue);
|
||||
|
||||
const queued = orderBy(
|
||||
result.queued,
|
||||
(game) => game.downloadQueue?.id ?? -1,
|
||||
["desc"]
|
||||
);
|
||||
|
||||
const complete = orderBy(result.complete, (game) =>
|
||||
game.progress === 1 ? 0 : 1
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
queued,
|
||||
complete,
|
||||
};
|
||||
}, [library, lastPacket?.game.id]);
|
||||
|
||||
const downloadGroups = [
|
||||
{
|
||||
title: t("download_in_progress"),
|
||||
library: libraryGroup.downloading,
|
||||
},
|
||||
{
|
||||
title: t("queued_downloads"),
|
||||
library: libraryGroup.queued,
|
||||
},
|
||||
{
|
||||
title: t("downloads_completed"),
|
||||
library: libraryGroup.complete,
|
||||
},
|
||||
];
|
||||
|
||||
const hasItemsInLibrary = useMemo(() => {
|
||||
return Object.values(libraryGroup).some((group) => group.length > 0);
|
||||
}, [libraryGroup]);
|
||||
|
||||
return (
|
||||
<section className={styles.downloadsContainer}>
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
|
@ -241,55 +112,31 @@ export function Downloads() {
|
|||
deleteGame={handleDeleteGame}
|
||||
/>
|
||||
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
|
||||
<ul className={styles.downloads}>
|
||||
{filteredLibrary.map((game) => {
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.download({
|
||||
cancelled: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<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(buildGameDetailsPath(game))}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadActions}>
|
||||
{getGameActions(game)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{hasItemsInLibrary ? (
|
||||
<section className={styles.downloadsContainer}>
|
||||
<div className={styles.downloadGroups}>
|
||||
{downloadGroups.map((group) => (
|
||||
<DownloadGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
library={group.library}
|
||||
openDeleteGameModal={handleOpenDeleteGameModal}
|
||||
openGameInstaller={handleOpenGameInstaller}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className={styles.noDownloads}>
|
||||
<div className={styles.arrowIcon}>
|
||||
<ArrowDownIcon size={24} />
|
||||
</div>
|
||||
<h2>{t("no_downloads_title")}</h2>
|
||||
<p style={{ fontFamily: "Fira Sans" }}>
|
||||
{t("no_downloads_description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export function GallerySlider() {
|
|||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
|
||||
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
|
||||
const hasMovies = shopDetails && shopDetails.movies?.length;
|
||||
|
||||
const mediaCount = useMemo(() => {
|
||||
|
@ -77,7 +77,7 @@ export function GallerySlider() {
|
|||
|
||||
const previews = useMemo(() => {
|
||||
const screenshotPreviews =
|
||||
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
|
||||
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
|
||||
id,
|
||||
thumbnail: path_thumbnail,
|
||||
})) ?? [];
|
||||
|
@ -121,7 +121,7 @@ export function GallerySlider() {
|
|||
))}
|
||||
|
||||
{hasScreenshots &&
|
||||
shopDetails.screenshots.map((image, i) => (
|
||||
shopDetails.screenshots?.map((image, i) => (
|
||||
<img
|
||||
key={image.id}
|
||||
className={styles.gallerySliderMedia}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useParams, useSearchParams } from "react-router-dom";
|
|||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
||||
import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
||||
|
||||
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
|
||||
|
||||
|
@ -16,11 +16,13 @@ import {
|
|||
RepacksModal,
|
||||
} from "./modals";
|
||||
import { Downloader } from "@shared";
|
||||
import { GameOptionsModal } from "./modals/game-options-modal";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
game: Game | null;
|
||||
shopDetails: ShopDetails | null;
|
||||
repacks: GameRepack[];
|
||||
shop: GameShop;
|
||||
gameTitle: string;
|
||||
isGameRunning: boolean;
|
||||
isLoading: boolean;
|
||||
|
@ -28,6 +30,8 @@ export interface GameDetailsContext {
|
|||
gameColor: string;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
openRepacksModal: () => void;
|
||||
openGameOptionsModal: () => void;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
updateGame: () => Promise<void>;
|
||||
}
|
||||
|
||||
|
@ -35,6 +39,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||
game: null,
|
||||
shopDetails: null,
|
||||
repacks: [],
|
||||
shop: "steam",
|
||||
gameTitle: "",
|
||||
isGameRunning: false,
|
||||
isLoading: false,
|
||||
|
@ -42,6 +47,8 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||
gameColor: "",
|
||||
setGameColor: () => {},
|
||||
openRepacksModal: () => {},
|
||||
openGameOptionsModal: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
updateGame: async () => {},
|
||||
});
|
||||
|
||||
|
@ -68,6 +75,7 @@ export function GameDetailsContextProvider({
|
|||
>(null);
|
||||
const [isGameRunning, setisGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
|
@ -79,6 +87,10 @@ export function GameDetailsContextProvider({
|
|||
|
||||
const { startDownload, lastPacket } = useDownload();
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
|
@ -92,7 +104,7 @@ export function GameDetailsContextProvider({
|
|||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
Promise.allSettled([
|
||||
window.electron.getGameShopDetails(
|
||||
objectID!,
|
||||
shop as GameShop,
|
||||
|
@ -100,9 +112,12 @@ export function GameDetailsContextProvider({
|
|||
),
|
||||
window.electron.searchGameRepacks(gameTitle),
|
||||
])
|
||||
.then(([appDetails, repacks]) => {
|
||||
if (appDetails) setGameDetails(appDetails);
|
||||
setRepacks(repacks);
|
||||
.then(([appDetailsResult, repacksResult]) => {
|
||||
if (appDetailsResult.status === "fulfilled")
|
||||
setGameDetails(appDetailsResult.value);
|
||||
|
||||
if (repacksResult.status === "fulfilled")
|
||||
setRepacks(repacksResult.value);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
@ -153,6 +168,7 @@ export function GameDetailsContextProvider({
|
|||
|
||||
await updateGame();
|
||||
setShowRepacksModal(false);
|
||||
setShowGameOptionsModal(false);
|
||||
|
||||
if (
|
||||
repack.repacker === "onlinefix" &&
|
||||
|
@ -167,13 +183,43 @@ export function GameDetailsContextProvider({
|
|||
}
|
||||
};
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
};
|
||||
|
||||
const selectGameExecutable = async () => {
|
||||
const downloadsPath = await getDownloadsPath();
|
||||
|
||||
return window.electron
|
||||
.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
defaultPath: downloadsPath,
|
||||
filters: [
|
||||
{
|
||||
name: "Game executable",
|
||||
extensions: ["exe"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then(({ filePaths }) => {
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
return filePaths[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const openRepacksModal = () => setShowRepacksModal(true);
|
||||
const openGameOptionsModal = () => setShowGameOptionsModal(true);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
game,
|
||||
shopDetails,
|
||||
shop: shop as GameShop,
|
||||
repacks,
|
||||
gameTitle,
|
||||
isGameRunning,
|
||||
|
@ -182,6 +228,8 @@ export function GameDetailsContextProvider({
|
|||
gameColor,
|
||||
setGameColor,
|
||||
openRepacksModal,
|
||||
openGameOptionsModal,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
}}
|
||||
>
|
||||
|
@ -202,6 +250,16 @@ export function GameDetailsContextProvider({
|
|||
onClose={() => setShowInstructionsModal(null)}
|
||||
/>
|
||||
|
||||
{game && (
|
||||
<GameOptionsModal
|
||||
visible={showGameOptionsModal}
|
||||
game={game}
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</>
|
||||
</Provider>
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { vars } from "../../../theme.css";
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const heroPanelAction = style({
|
||||
border: `solid 1px ${vars.color.muted}`,
|
||||
});
|
||||
|
||||
export const actions = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const separator = style({
|
||||
width: "1px",
|
||||
backgroundColor: vars.color.muted,
|
||||
opacity: "0.2",
|
||||
});
|
||||
|
|
|
@ -1,28 +1,16 @@
|
|||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||
|
||||
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
||||
|
||||
import { GearIcon, PlayIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { 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 function HeroPanelActions() {
|
||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||
useState(false);
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
|
||||
const {
|
||||
resumeDownload,
|
||||
pauseDownload,
|
||||
cancelDownload,
|
||||
removeGameFromLibrary,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
const { isGameDeleting } = useDownload();
|
||||
|
||||
const {
|
||||
game,
|
||||
|
@ -31,61 +19,20 @@ export function HeroPanelActions() {
|
|||
objectID,
|
||||
gameTitle,
|
||||
openRepacksModal,
|
||||
openGameOptionsModal,
|
||||
updateGame,
|
||||
selectGameExecutable,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
};
|
||||
|
||||
const selectGameExecutable = async () => {
|
||||
const downloadsPath = await getDownloadsPath();
|
||||
|
||||
return window.electron
|
||||
.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
defaultPath: downloadsPath,
|
||||
filters: [
|
||||
{
|
||||
name: "Game executable",
|
||||
extensions: ["exe"],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then(({ filePaths }) => {
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
return filePaths[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGameOnLibrary = async () => {
|
||||
const addGameToLibrary = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (game) {
|
||||
await removeGameFromLibrary(game.id);
|
||||
} else {
|
||||
const gameExecutablePath = await selectGameExecutable();
|
||||
|
||||
await window.electron.addGameToLibrary(
|
||||
objectID!,
|
||||
gameTitle,
|
||||
"steam",
|
||||
gameExecutablePath
|
||||
);
|
||||
}
|
||||
await window.electron.addGameToLibrary(objectID!, gameTitle, "steam");
|
||||
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
|
@ -94,15 +41,6 @@ export function HeroPanelActions() {
|
|||
}
|
||||
};
|
||||
|
||||
const openGameInstaller = () => {
|
||||
if (game) {
|
||||
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openGame = async () => {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
|
@ -110,11 +48,6 @@ export function HeroPanelActions() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (game?.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const gameExecutablePath = await selectGameExecutable();
|
||||
if (gameExecutablePath)
|
||||
window.electron.openGame(game.id, gameExecutablePath);
|
||||
|
@ -127,144 +60,76 @@ export function HeroPanelActions() {
|
|||
|
||||
const deleting = game ? isGameDeleting(game?.id) : false;
|
||||
|
||||
const toggleGameOnLibraryButton = (
|
||||
const addGameToLibraryButton = (
|
||||
<Button
|
||||
theme="outline"
|
||||
disabled={toggleLibraryGameDisabled}
|
||||
onClick={toggleGameOnLibrary}
|
||||
onClick={addGameToLibrary}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{game ? <NoEntryIcon /> : <PlusCircleIcon />}
|
||||
{game ? t("remove_from_library") : t("add_to_library")}
|
||||
<PlusCircleIcon />
|
||||
{t("add_to_library")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (game?.status === "active" && game?.progress !== 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => pauseDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => cancelDownload(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
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(updateGame)}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "removed") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={openRepacksModal}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("remove_from_list")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const showDownloadOptionsButton = (
|
||||
<Button
|
||||
onClick={openRepacksModal}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (repacks.length && !game) {
|
||||
return (
|
||||
<>
|
||||
{toggleGameOnLibraryButton}
|
||||
<Button
|
||||
onClick={openRepacksModal}
|
||||
theme="outline"
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{addGameToLibraryButton}
|
||||
{showDownloadOptionsButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{game?.progress === 1 ? (
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
|
||||
if (game) {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
{isGameRunning ? (
|
||||
<Button
|
||||
onClick={openGameInstaller}
|
||||
onClick={closeGame}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openGame}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("install")}
|
||||
<PlayIcon />
|
||||
{t("play")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
toggleGameOnLibraryButton
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
{isGameRunning ? (
|
||||
<Button
|
||||
onClick={closeGame}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openGame}
|
||||
onClick={openGameOptionsModal}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
{t("play")}
|
||||
<GearIcon />
|
||||
{t("options")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return addGameToLibraryButton;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useDate, useDownload } from "@renderer/hooks";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
import { Link } from "@renderer/components";
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
|
@ -13,6 +15,8 @@ export function HeroPanelPlaytime() {
|
|||
|
||||
const { i18n, t } = useTranslation("game_details");
|
||||
|
||||
const { progress, lastPacket } = useDownload();
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -46,8 +50,45 @@ 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) return null;
|
||||
|
||||
const hasDownload =
|
||||
["active", "paused"].includes(game.status) && game.progress !== 1;
|
||||
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
|
||||
const downloadInProgressInfo = (
|
||||
<div className={styles.downloadDetailsRow}>
|
||||
<Link to="/downloads" className={styles.downloadsLink}>
|
||||
{game.status === "active"
|
||||
? t("download_in_progress")
|
||||
: t("download_paused")}
|
||||
</Link>
|
||||
|
||||
<small>
|
||||
{isGameDownloading ? progress : formatDownloadProgress(game.progress)}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!game.lastTimePlayed) {
|
||||
return (
|
||||
<>
|
||||
<p>{t("not_played_yet", { title: game?.title })}</p>
|
||||
{hasDownload && downloadInProgressInfo}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameRunning) {
|
||||
return (
|
||||
<>
|
||||
<p>{t("playing_now")}</p>
|
||||
|
||||
{hasDownload && downloadInProgressInfo}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -58,8 +99,8 @@ export function HeroPanelPlaytime() {
|
|||
})}
|
||||
</p>
|
||||
|
||||
{isGameRunning ? (
|
||||
<p>{t("playing_now")}</p>
|
||||
{hasDownload ? (
|
||||
downloadInProgressInfo
|
||||
) : (
|
||||
<p>
|
||||
{t("last_time_played", {
|
||||
|
|
|
@ -28,9 +28,15 @@ export const actions = style({
|
|||
});
|
||||
|
||||
export const downloadDetailsRow = style({
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
color: vars.color.body,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const downloadsLink = style({
|
||||
color: vars.color.body,
|
||||
textDecoration: "underline",
|
||||
});
|
||||
|
||||
export const progressBar = recipe({
|
||||
|
|
|
@ -1,14 +1,9 @@
|
|||
import { format } from "date-fns";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Color from "color";
|
||||
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { HeroPanelActions } from "./hero-panel-actions";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
|
@ -18,98 +13,31 @@ export function HeroPanel() {
|
|||
|
||||
const { game, repacks, gameColor } = useContext(gameDetailsContext);
|
||||
|
||||
const { progress, eta, lastPacket, isGameDeleting } = useDownload();
|
||||
|
||||
const finalDownloadSize = useMemo(() => {
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
||||
if (lastPacket?.game.fileSize && game?.status === "active")
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
}, [game, lastPacket?.game]);
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||
|
||||
const getInfo = () => {
|
||||
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
|
||||
if (!game) {
|
||||
const [latestRepack] = repacks;
|
||||
|
||||
if (game?.progress === 1) return <HeroPanelPlaytime />;
|
||||
if (latestRepack) {
|
||||
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
|
||||
const repacksCount = repacks.length;
|
||||
|
||||
if (game?.status === "active") {
|
||||
if (lastPacket?.isDownloadingMetadata && isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("downloading_metadata")}</p>
|
||||
<p>{t("updated_at", { updated_at: lastUpdate })}</p>
|
||||
<p>{t("download_options", { count: repacksCount })}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeDownloaded = formatBytes(
|
||||
lastPacket?.game?.bytesDownloaded ?? game?.bytesDownloaded
|
||||
);
|
||||
|
||||
const showPeers =
|
||||
game?.downloader === Downloader.Torrent &&
|
||||
lastPacket?.numPeers !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{isGameDownloading
|
||||
? progress
|
||||
: formatDownloadProgress(game?.progress)}
|
||||
|
||||
<small>{eta ? t("eta", { eta }) : t("calculating_eta")}</small>
|
||||
</p>
|
||||
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
<span>
|
||||
{sizeDownloaded} / {finalDownloadSize}
|
||||
</span>
|
||||
{showPeers && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
return <p>{t("no_downloads")}</p>;
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
const formattedProgress = formatDownloadProgress(game.progress);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{formattedProgress} <small>{t("paused")}</small>
|
||||
</p>
|
||||
<p>
|
||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const [latestRepack] = repacks;
|
||||
|
||||
if (latestRepack) {
|
||||
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
|
||||
const repacksCount = repacks.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{t("updated_at", { updated_at: lastUpdate })}</p>
|
||||
<p>{t("download_options", { count: repacksCount })}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t("no_downloads")}</p>;
|
||||
return <HeroPanelPlaytime />;
|
||||
};
|
||||
|
||||
const backgroundColor = gameColor
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT } from "../../../theme.css";
|
||||
|
||||
export const optionsContainer = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const gameOptionHeader = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const gameOptionHeaderDescription = style({
|
||||
fontFamily: "'Fira Sans', sans-serif",
|
||||
fontWeight: "400",
|
||||
});
|
||||
|
||||
export const gameOptionRow = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
|
@ -0,0 +1,192 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { Game } from "@types";
|
||||
import * as styles from "./game-options-modal.css";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
|
||||
|
||||
export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
}: GameOptionsModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { updateGame, openRepacksModal, selectGameExecutable } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
|
||||
|
||||
const {
|
||||
removeGameInstaller,
|
||||
removeGameFromLibrary,
|
||||
isGameDeleting,
|
||||
cancelDownload,
|
||||
} = useDownload();
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
|
||||
const handleRemoveGameFromLibrary = async () => {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.id);
|
||||
}
|
||||
|
||||
await removeGameFromLibrary(game.id);
|
||||
updateGame();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChangeExecutableLocation = async () => {
|
||||
const path = await selectGameExecutable();
|
||||
|
||||
if (path) {
|
||||
await window.electron.updateExecutablePath(game.id, path);
|
||||
updateGame();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = async () => {
|
||||
await window.electron.createGameShortcut(game.id);
|
||||
};
|
||||
|
||||
const handleOpenDownloadFolder = async () => {
|
||||
await window.electron.openGameInstallerPath(game.id);
|
||||
};
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
await removeGameInstaller(game.id);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleOpenGameExecutablePath = async () => {
|
||||
await window.electron.openGameExecutablePath(game.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteGameModal
|
||||
visible={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
deleteGame={handleDeleteGame}
|
||||
/>
|
||||
|
||||
<RemoveGameFromLibraryModal
|
||||
visible={showRemoveGameModal}
|
||||
onClose={() => setShowRemoveGameModal(false)}
|
||||
removeGameFromLibrary={handleRemoveGameFromLibrary}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={game.title}
|
||||
onClose={onClose}
|
||||
large={true}
|
||||
>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("executable_section_title")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
{t("executable_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className={styles.gameOptionRow}>
|
||||
<TextField
|
||||
value={game.executablePath || ""}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
placeholder={t("no_executable_selected")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleChangeExecutableLocation}
|
||||
>
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{game.executablePath && (
|
||||
<div className={styles.gameOptionRow}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleOpenGameExecutablePath}
|
||||
>
|
||||
{t("open_folder")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateShortcut} theme="outline">
|
||||
{t("create_shortcut")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("downloads_secion_title")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className={styles.gameOptionRow}>
|
||||
<Button
|
||||
onClick={openRepacksModal}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameDownloading}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{game.downloadPath && (
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("danger_zone_section_title")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
{t("danger_zone_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className={styles.gameOptionRow}>
|
||||
<Button
|
||||
onClick={() => setShowRemoveGameModal(true)}
|
||||
theme="danger"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("remove_from_library")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={isGameDownloading || deleting || !game.downloadPath}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -46,7 +46,9 @@ export function DODIInstallationGuide({
|
|||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontFamily: "Fira Sans", marginBottom: 8 }}>
|
||||
<p
|
||||
style={{ fontFamily: "Fira Sans", marginBottom: `${SPACING_UNIT}px` }}
|
||||
>
|
||||
<Trans i18nKey="dodi_installation_instruction" ns="game_details">
|
||||
<ArrowUpIcon size={16} />
|
||||
</Trans>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { SPACING_UNIT } from "../../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const deleteActionsButtonsCtn = style({
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "end",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import * as styles from "./remove-from-library-modal.css";
|
||||
import { Game } from "@types";
|
||||
|
||||
interface RemoveGameFromLibraryModalProps {
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
onClose: () => void;
|
||||
removeGameFromLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function RemoveGameFromLibraryModal({
|
||||
onClose,
|
||||
game,
|
||||
visible,
|
||||
removeGameFromLibrary,
|
||||
}: RemoveGameFromLibraryModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const handleRemoveGame = async () => {
|
||||
await removeGameFromLibrary();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("remove_from_library_title")}
|
||||
description={t("remove_from_library_description", { game: game.title })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.deleteActionsButtonsCtn}>
|
||||
<Button onClick={handleRemoveGame} theme="outline">
|
||||
{t("remove")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import parseTorrent from "parse-torrent";
|
||||
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { Badge, Button, Modal, TextField } from "@renderer/components";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
import * as styles from "./repacks-modal.css";
|
||||
|
@ -31,13 +32,22 @@ export function RepacksModal({
|
|||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
|
||||
const { repacks } = useContext(gameDetailsContext);
|
||||
const [infoHash, setInfoHash] = useState("");
|
||||
|
||||
const { repacks, game } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const getInfoHash = useCallback(async () => {
|
||||
const torrent = await parseTorrent(game?.uri ?? "");
|
||||
setInfoHash(torrent.infoHash ?? "");
|
||||
}, [game]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(repacks);
|
||||
}, [repacks, visible]);
|
||||
|
||||
if (game?.uri) getInfoHash();
|
||||
}, [repacks, visible, game, getInfoHash]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
|
@ -89,6 +99,11 @@ export function RepacksModal({
|
|||
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}>
|
||||
{repack.title}
|
||||
</p>
|
||||
|
||||
{repack.magnet.toLowerCase().includes(infoHash) && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: "12px" }}>
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const homeCategories = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const homeHeader = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import { Button, GameCard, Hero } from "@renderer/components";
|
||||
import {
|
||||
Steam250Game,
|
||||
type CatalogueCategory,
|
||||
type CatalogueEntry,
|
||||
} from "@types";
|
||||
import type { Steam250Game, CatalogueEntry } from "@types";
|
||||
|
||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
||||
|
@ -18,8 +14,6 @@ import { vars } from "../../theme.css";
|
|||
import Lottie from "lottie-react";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
const categories: CatalogueCategory[] = ["trending", "recently_added"];
|
||||
|
||||
export function Home() {
|
||||
const { t } = useTranslation("home");
|
||||
const navigate = useNavigate();
|
||||
|
@ -27,22 +21,15 @@ export function Home() {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [catalogue, setCatalogue] = useState<CatalogueEntry[]>([]);
|
||||
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, CatalogueEntry[]>
|
||||
>({
|
||||
trending: [],
|
||||
recently_added: [],
|
||||
});
|
||||
|
||||
const getCatalogue = useCallback((category: CatalogueCategory) => {
|
||||
const getCatalogue = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
window.electron
|
||||
.getCatalogue(category)
|
||||
.getCatalogue()
|
||||
.then((catalogue) => {
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
setCatalogue(catalogue);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
|
@ -50,15 +37,6 @@ export function Home() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const currentCategory = searchParams.get("category") || categories[0];
|
||||
|
||||
const handleSelectCategory = (category: CatalogueCategory) => {
|
||||
if (category !== currentCategory) {
|
||||
getCatalogue(category);
|
||||
navigate(`/?category=${category}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRandomGame = useCallback(() => {
|
||||
window.electron.getRandomGame().then((game) => {
|
||||
if (game) setRandomGame(game);
|
||||
|
@ -80,9 +58,10 @@ export function Home() {
|
|||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getCatalogue(currentCategory as CatalogueCategory);
|
||||
getCatalogue();
|
||||
|
||||
getRandomGame();
|
||||
}, [getCatalogue, currentCategory, getRandomGame]);
|
||||
}, [getCatalogue, getRandomGame]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
|
@ -92,17 +71,7 @@ export function Home() {
|
|||
<Hero />
|
||||
|
||||
<section className={styles.homeHeader}>
|
||||
<div className={styles.homeCategories}>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
theme={currentCategory === category ? "primary" : "outline"}
|
||||
onClick={() => handleSelectCategory(category)}
|
||||
>
|
||||
{t(category)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<h2>{t("trending")}</h2>
|
||||
|
||||
<Button
|
||||
onClick={handleRandomizerClick}
|
||||
|
@ -120,14 +89,12 @@ export function Home() {
|
|||
</Button>
|
||||
</section>
|
||||
|
||||
<h2>{t(currentCategory)}</h2>
|
||||
|
||||
<section className={styles.cards}>
|
||||
{isLoading
|
||||
? Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))
|
||||
: catalogue[currentCategory as CatalogueCategory].map((result) => (
|
||||
: catalogue.map((result) => (
|
||||
<GameCard
|
||||
key={result.objectID}
|
||||
game={result}
|
||||
|
|
118
src/renderer/src/pages/settings/add-download-source-modal.tsx
Normal file
118
src/renderer/src/pages/settings/add-download-source-modal.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-download-sources.css";
|
||||
|
||||
interface AddDownloadSourceModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onAddDownloadSource: () => void;
|
||||
}
|
||||
|
||||
export function AddDownloadSourceModal({
|
||||
visible,
|
||||
onClose,
|
||||
onAddDownloadSource,
|
||||
}: AddDownloadSourceModalProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
name: string;
|
||||
downloadCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("");
|
||||
setIsLoading(false);
|
||||
setValidationResult(null);
|
||||
}, [visible]);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const handleValidateDownloadSource = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await window.electron.validateDownloadSource(value);
|
||||
setValidationResult(result);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
await window.electron.addDownloadSource(value);
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("add_download_source")}
|
||||
description={t("add_download_source_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
minWidth: "500px",
|
||||
}}
|
||||
>
|
||||
<div className={styles.downloadSourceField}>
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
placeholder="Insert a valid JSON url"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
onClick={handleValidateDownloadSource}
|
||||
disabled={isLoading || !value}
|
||||
>
|
||||
{t("validate_download_source")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validationResult && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: `${SPACING_UNIT * 3}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
}}
|
||||
>
|
||||
<h4>{validationResult?.name}</h4>
|
||||
<small>
|
||||
Found{" "}
|
||||
{validationResult?.downloadCount.toLocaleString(undefined)}{" "}
|
||||
download options
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={handleAddDownloadSource}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const downloadSourceField = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadSources = style({
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const downloadSourceItem = recipe({
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
borderRadius: "8px",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
transition: "all ease 0.2s",
|
||||
},
|
||||
variants: {
|
||||
isSyncing: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadSourceItemHeader = style({
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadSourcesHeader = style({
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const separator = style({
|
||||
height: "100%",
|
||||
width: "1px",
|
||||
backgroundColor: vars.color.border,
|
||||
margin: `${SPACING_UNIT}px 0`,
|
||||
});
|
164
src/renderer/src/pages/settings/settings-download-sources.tsx
Normal file
164
src/renderer/src/pages/settings/settings-download-sources.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TextField, Button, Badge } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-download-sources.css";
|
||||
import type { DownloadSource } from "@types";
|
||||
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
|
||||
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
|
||||
useState(false);
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
|
||||
useState(false);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
return window.electron.getDownloadSources().then((sources) => {
|
||||
setDownloadSources(sources);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDownloadSources();
|
||||
}, []);
|
||||
|
||||
const handleRemoveSource = async (id: number) => {
|
||||
await window.electron.removeDownloadSource(id);
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
|
||||
getDownloadSources();
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
await getDownloadSources();
|
||||
showSuccessToast(t("added_download_source"));
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
window.electron
|
||||
.syncDownloadSources()
|
||||
.then(() => {
|
||||
showSuccessToast(t("download_sources_synced"));
|
||||
getDownloadSources();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSyncingDownloadSources(false);
|
||||
});
|
||||
};
|
||||
|
||||
const statusTitle = {
|
||||
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
|
||||
[DownloadSourceStatus.Errored]: t("download_source_errored"),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddDownloadSourceModal
|
||||
visible={showAddDownloadSourceModal}
|
||||
onClose={() => setShowAddDownloadSourceModal(false)}
|
||||
onAddDownloadSource={handleAddDownloadSource}
|
||||
/>
|
||||
|
||||
<p style={{ fontFamily: '"Fira Sans"' }}>
|
||||
{t("download_sources_description")}
|
||||
</p>
|
||||
|
||||
<div className={styles.downloadSourcesHeader}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
disabled={!downloadSources.length || isSyncingDownloadSources}
|
||||
onClick={syncDownloadSources}
|
||||
>
|
||||
<SyncIcon />
|
||||
{t("sync_download_sources")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => setShowAddDownloadSourceModal(true)}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{t("add_download_source")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className={styles.downloadSources}>
|
||||
{downloadSources.map((downloadSource) => (
|
||||
<li
|
||||
key={downloadSource.id}
|
||||
className={styles.downloadSourceItem({
|
||||
isSyncing: isSyncingDownloadSources,
|
||||
})}
|
||||
>
|
||||
<div className={styles.downloadSourceItemHeader}>
|
||||
<h2>{downloadSource.name}</h2>
|
||||
|
||||
<div style={{ display: "flex" }}>
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
count: downloadSource.downloadCount,
|
||||
countFormatted:
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<small>
|
||||
{t("download_options", {
|
||||
count: downloadSource.repackCount,
|
||||
countFormatted: downloadSource.repackCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadSourceField}>
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
value={downloadSource.url}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
onClick={() => handleRemoveSource(downloadSource.id)}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import ISO6391 from "iso-639-1";
|
||||
|
||||
import { TextField, Button, CheckboxField, Select } from "@renderer/components";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
} from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./settings-general.css";
|
||||
import type { UserPreferences } from "@types";
|
||||
|
@ -125,18 +130,16 @@ export function SettingsGeneral({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<h3>{t("language")}</h3>
|
||||
<>
|
||||
<Select
|
||||
value={form.language}
|
||||
onChange={handleLanguageChange}
|
||||
options={languageOptions.map((language) => ({
|
||||
key: language.option,
|
||||
value: language.option,
|
||||
label: language.nativeName,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
<SelectField
|
||||
label={t("language")}
|
||||
value={form.language}
|
||||
onChange={handleLanguageChange}
|
||||
options={languageOptions.map((language) => ({
|
||||
key: language.option,
|
||||
value: language.option,
|
||||
label: language.nativeName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<h3>{t("notifications")}</h3>
|
||||
<>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { SettingsGeneral } from "./settings-general";
|
|||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { setUserPreferences } from "@renderer/features";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
@ -16,9 +17,10 @@ export function Settings() {
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const categories = [
|
||||
{ name: t("general"), component: SettingsGeneral },
|
||||
{ name: t("behavior"), component: SettingsBehavior },
|
||||
{ name: "Real-Debrid", component: SettingsRealDebrid },
|
||||
t("general"),
|
||||
t("behavior"),
|
||||
t("download_sources"),
|
||||
"Real-Debrid",
|
||||
];
|
||||
|
||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||
|
@ -33,9 +35,24 @@ export function Settings() {
|
|||
};
|
||||
|
||||
const renderCategory = () => {
|
||||
const CategoryComponent = categories[currentCategoryIndex].component;
|
||||
if (currentCategoryIndex === 0) {
|
||||
return (
|
||||
<SettingsGeneral updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 1) {
|
||||
return (
|
||||
<SettingsBehavior updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 2) {
|
||||
return <SettingsDownloadSources />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CategoryComponent updateUserPreferences={handleUpdateUserPreferences} />
|
||||
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -45,16 +62,16 @@ export function Settings() {
|
|||
<section className={styles.settingsCategories}>
|
||||
{categories.map((category, index) => (
|
||||
<Button
|
||||
key={category.name}
|
||||
key={category}
|
||||
theme={currentCategoryIndex === index ? "primary" : "outline"}
|
||||
onClick={() => setCurrentCategoryIndex(index)}
|
||||
>
|
||||
{category.name}
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<h2>{categories[currentCategoryIndex].name}</h2>
|
||||
<h2>{categories[currentCategoryIndex]}</h2>
|
||||
{renderCategory()}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -6,10 +6,10 @@ interface BinaryNotFoundModalProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BinaryNotFoundModal = ({
|
||||
export function BinaryNotFoundModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: BinaryNotFoundModalProps) => {
|
||||
}: BinaryNotFoundModalProps) {
|
||||
const { t } = useTranslation("binary_not_found_modal");
|
||||
|
||||
return (
|
||||
|
@ -22,4 +22,4 @@ export const BinaryNotFoundModal = ({
|
|||
{t("instructions")}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export const [themeClass, vars] = createTheme({
|
|||
border: "#424244",
|
||||
success: "#1c9749",
|
||||
danger: "#e11d48",
|
||||
warning: "#ffc107",
|
||||
},
|
||||
opacity: {
|
||||
disabled: "0.5",
|
||||
|
@ -18,5 +19,6 @@ export const [themeClass, vars] = createTheme({
|
|||
},
|
||||
size: {
|
||||
body: "14px",
|
||||
small: "12px",
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue