mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'main' into feature/better-repack-modal
This commit is contained in:
commit
dce700b189
100 changed files with 2762 additions and 1267 deletions
|
@ -20,6 +20,7 @@ import {
|
|||
setRepackersFriendlyNames,
|
||||
toggleDraggingDisabled,
|
||||
} from "@renderer/features";
|
||||
import { GameStatusHelper } from "@shared";
|
||||
|
||||
document.body.classList.add(themeClass);
|
||||
|
||||
|
@ -31,7 +32,7 @@ export function App({ children }: AppProps) {
|
|||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { clearDownload, addPacket } = useDownload();
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
@ -57,20 +58,20 @@ export function App({ children }: AppProps) {
|
|||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onDownloadProgress(
|
||||
(downloadProgress) => {
|
||||
if (downloadProgress.game.progress === 1) {
|
||||
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
|
||||
clearDownload();
|
||||
updateLibrary();
|
||||
return;
|
||||
}
|
||||
|
||||
addPacket(downloadProgress);
|
||||
setLastPacket(downloadProgress);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [clearDownload, addPacket, updateLibrary]);
|
||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="#8e919b" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M41.625,10.76953c-3.98047,-3.20313 -10.27734,-3.74609 -10.54687,-3.76563c-0.41797,-0.03516 -0.81641,0.19922 -0.98828,0.58594c-0.01562,0.02344 -0.15234,0.33984 -0.30469,0.83203c2.63281,0.44531 5.86719,1.33984 8.79297,3.15625c0.46875,0.28906 0.61328,0.90625 0.32422,1.375c-0.19141,0.30859 -0.51562,0.47656 -0.85156,0.47656c-0.17969,0 -0.36328,-0.05078 -0.52734,-0.15234c-5.03125,-3.12109 -11.3125,-3.27734 -12.52344,-3.27734c-1.21094,0 -7.49609,0.15625 -12.52344,3.27734c-0.46875,0.29297 -1.08594,0.14844 -1.375,-0.32031c-0.29297,-0.47266 -0.14844,-1.08594 0.32031,-1.37891c2.92578,-1.8125 6.16016,-2.71094 8.79297,-3.15234c-0.15234,-0.49609 -0.28906,-0.80859 -0.30078,-0.83594c-0.17578,-0.38672 -0.57031,-0.62891 -0.99219,-0.58594c-0.26953,0.01953 -6.56641,0.5625 -10.60156,3.80859c-2.10547,1.94922 -6.32031,13.33984 -6.32031,23.1875c0,0.17578 0.04688,0.34375 0.13281,0.49609c2.90625,5.10938 10.83984,6.44531 12.64844,6.50391c0.00781,0 0.01953,0 0.03125,0c0.32031,0 0.62109,-0.15234 0.80859,-0.41016l1.82813,-2.51562c-4.93359,-1.27344 -7.45312,-3.4375 -7.59766,-3.56641c-0.41406,-0.36328 -0.45312,-0.99609 -0.08594,-1.41016c0.36328,-0.41406 0.99609,-0.45312 1.41016,-0.08984c0.05859,0.05469 4.69922,3.99219 13.82422,3.99219c9.14063,0 13.78125,-3.95312 13.82813,-3.99219c0.41406,-0.35937 1.04297,-0.32422 1.41016,0.09375c0.36328,0.41406 0.32422,1.04297 -0.08984,1.40625c-0.14453,0.12891 -2.66406,2.29297 -7.59766,3.56641l1.82813,2.51563c0.1875,0.25781 0.48828,0.41016 0.80859,0.41016c0.01172,0 0.02344,0 0.03125,0c1.80859,-0.05859 9.74219,-1.39453 12.64844,-6.50391c0.08594,-0.15234 0.13281,-0.32031 0.13281,-0.49609c0,-9.84766 -4.21484,-21.23828 -6.375,-23.23047zM18.5,30c-1.93359,0 -3.5,-1.78906 -3.5,-4c0,-2.21094 1.56641,-4 3.5,-4c1.93359,0 3.5,1.78906 3.5,4c0,2.21094 -1.56641,4 -3.5,4zM31.5,30c-1.93359,0 -3.5,-1.78906 -3.5,-4c0,-2.21094 1.56641,-4 3.5,-4c1.93359,0 3.5,1.78906 3.5,4c0,2.21094 -1.56641,4 -3.5,4z"></path></g></g></svg>
|
Before Width: | Height: | Size: 2.3 KiB |
1
src/renderer/src/assets/telegram-icon.svg
Normal file
1
src/renderer/src/assets/telegram-icon.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M41.4193 7.30899C41.4193 7.30899 45.3046 5.79399 44.9808 9.47328C44.8729 10.9883 43.9016 16.2908 43.1461 22.0262L40.5559 39.0159C40.5559 39.0159 40.3401 41.5048 38.3974 41.9377C36.4547 42.3705 33.5408 40.4227 33.0011 39.9898C32.5694 39.6652 24.9068 34.7955 22.2086 32.4148C21.4531 31.7655 20.5897 30.4669 22.3165 28.9519L33.6487 18.1305C34.9438 16.8319 36.2389 13.8019 30.8426 17.4812L15.7331 27.7616C15.7331 27.7616 14.0063 28.8437 10.7686 27.8698L3.75342 25.7055C3.75342 25.7055 1.16321 24.0823 5.58815 22.459C16.3807 17.3729 29.6555 12.1786 41.4193 7.30899Z" fill="currentColor"></path> </g></svg>
|
After Width: | Height: | Size: 838 B |
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="#8e919b" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M5.91992,6l14.66211,21.375l-14.35156,16.625h3.17969l12.57617,-14.57812l10,14.57813h12.01367l-15.31836,-22.33008l13.51758,-15.66992h-3.16992l-11.75391,13.61719l-9.3418,-13.61719zM9.7168,8h7.16406l23.32227,34h-7.16406z"></path></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="currentColor" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M5.91992,6l14.66211,21.375l-14.35156,16.625h3.17969l12.57617,-14.57812l10,14.57813h12.01367l-15.31836,-22.33008l13.51758,-15.66992h-3.16992l-11.75391,13.61719l-9.3418,-13.61719zM9.7168,8h7.16406l23.32227,34h-7.16406z"></path></g></g></svg>
|
Before Width: | Height: | Size: 697 B After Width: | Height: | Size: 702 B |
|
@ -7,13 +7,17 @@ import { vars } from "../../theme.css";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VERSION_CODENAME } from "@renderer/constants";
|
||||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
|
||||
export function BottomPanel() {
|
||||
const { t } = useTranslation("bottom_panel");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { game, progress, downloadSpeed, eta, isDownloading } = useDownload();
|
||||
const { game, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game && GameStatusHelper.isDownloading(game.status ?? null);
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
|
@ -22,11 +26,11 @@ export function BottomPanel() {
|
|||
}, []);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isDownloading && game) {
|
||||
if (game.status === "downloading_metadata")
|
||||
if (isGameDownloading) {
|
||||
if (game.status === GameStatus.DownloadingMetadata)
|
||||
return t("downloading_metadata", { title: game.title });
|
||||
|
||||
if (game.status === "checking_files")
|
||||
if (game.status === GameStatus.CheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
|
@ -41,13 +45,13 @@ export function BottomPanel() {
|
|||
}
|
||||
|
||||
return t("no_downloads_in_progress");
|
||||
}, [t, game, progress, eta, isDownloading, downloadSpeed]);
|
||||
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={styles.bottomPanel}
|
||||
style={{
|
||||
background: isDownloading
|
||||
background: isGameDownloading
|
||||
? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
|
||||
: vars.color.darkBackground,
|
||||
}}
|
||||
|
@ -60,7 +64,7 @@ export function BottomPanel() {
|
|||
<small>{status}</small>
|
||||
</button>
|
||||
|
||||
<small>
|
||||
<small tabIndex={0}>
|
||||
v{version} "{VERSION_CODENAME}"
|
||||
</small>
|
||||
</footer>
|
||||
|
|
|
@ -19,6 +19,7 @@ const base = style({
|
|||
":disabled": {
|
||||
opacity: vars.opacity.disabled,
|
||||
pointerEvents: "none",
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -17,9 +17,9 @@ export function Button({
|
|||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className={cn(styles.button[theme], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
|
|
@ -24,7 +24,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
|||
/>
|
||||
{props.checked && <CheckIcon />}
|
||||
</div>
|
||||
<label htmlFor={id} className={styles.checkboxLabel}>
|
||||
<label htmlFor={id} className={styles.checkboxLabel} tabIndex={0}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -7,3 +7,4 @@ export * from "./modal/modal";
|
|||
export * from "./sidebar/sidebar";
|
||||
export * from "./text-field/text-field";
|
||||
export * from "./checkbox-field/checkbox-field";
|
||||
export * from "./link/link";
|
||||
|
|
9
src/renderer/src/components/link/link.css.ts
Normal file
9
src/renderer/src/components/link/link.css.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const link = style({
|
||||
textDecoration: "none",
|
||||
color: "#C0C1C7",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
33
src/renderer/src/components/link/link.tsx
Normal file
33
src/renderer/src/components/link/link.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Link as ReactRouterDomLink, LinkProps } from "react-router-dom";
|
||||
import cn from "classnames";
|
||||
import * as styles from "./link.css";
|
||||
|
||||
export function Link({ children, to, className, ...props }: LinkProps) {
|
||||
const openExternal = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
window.electron.openExternal(to as string);
|
||||
};
|
||||
|
||||
if (typeof to === "string" && to.startsWith("http")) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
className={cn(styles.link, className)}
|
||||
onClick={openExternal}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactRouterDomLink
|
||||
className={cn(styles.link, className)}
|
||||
to={to}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ReactRouterDomLink>
|
||||
);
|
||||
}
|
|
@ -10,10 +10,11 @@ import { useDownload, useLibrary } from "@renderer/hooks";
|
|||
import { routes } from "./routes";
|
||||
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
|
||||
import TelegramLogo from "@renderer/assets/telegram-icon.svg?react";
|
||||
import XLogo from "@renderer/assets/x-icon.svg?react";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
|
@ -35,9 +36,9 @@ export function Sidebar() {
|
|||
|
||||
const socials = [
|
||||
{
|
||||
url: "https://discord.gg/hydralauncher",
|
||||
icon: <DiscordLogo />,
|
||||
label: t("discord"),
|
||||
url: "https://t.me/hydralauncher",
|
||||
icon: <TelegramLogo />,
|
||||
label: t("telegram"),
|
||||
},
|
||||
{
|
||||
url: "https://twitter.com/hydralauncher",
|
||||
|
@ -60,9 +61,7 @@ export function Sidebar() {
|
|||
}, [gameDownloading?.id, updateLibrary]);
|
||||
|
||||
const isDownloading = library.some((game) =>
|
||||
["downloading", "checking_files", "downloading_metadata"].includes(
|
||||
game.status
|
||||
)
|
||||
GameStatusHelper.isDownloading(game.status)
|
||||
);
|
||||
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
|
@ -121,15 +120,14 @@ export function Sidebar() {
|
|||
}, [isResizing]);
|
||||
|
||||
const getGameTitle = (game: Game) => {
|
||||
if (game.status === "paused") return t("paused", { title: game.title });
|
||||
if (game.status === GameStatus.Paused)
|
||||
return t("paused", { title: game.title });
|
||||
|
||||
if (gameDownloading?.id === game.id) {
|
||||
const isVerifying = ["downloading_metadata", "checking_files"].includes(
|
||||
gameDownloading?.status
|
||||
);
|
||||
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
|
||||
|
||||
if (isVerifying)
|
||||
return t(gameDownloading.status, {
|
||||
return t(gameDownloading.status!, {
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
@ -204,7 +202,7 @@ export function Sidebar() {
|
|||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "cancelled",
|
||||
muted: game.status === GameStatus.Cancelled,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
|
|
|
@ -2,6 +2,13 @@ import { SPACING_UNIT, vars } from "../../theme.css";
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const textFieldContainer = style({
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const textField = recipe({
|
||||
base: {
|
||||
display: "inline-flex",
|
||||
|
@ -50,9 +57,3 @@ export const textFieldInput = style({
|
|||
cursor: "text",
|
||||
},
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
display: "block",
|
||||
color: vars.color.bodyText,
|
||||
});
|
||||
|
|
|
@ -8,29 +8,32 @@ export interface TextFieldProps
|
|||
HTMLInputElement
|
||||
> {
|
||||
theme?: NonNullable<RecipeVariants<typeof styles.textField>>["theme"];
|
||||
label?: string;
|
||||
label?: string | React.ReactNode;
|
||||
hint?: string | React.ReactNode;
|
||||
textFieldProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
containerProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
theme = "primary",
|
||||
label,
|
||||
hint,
|
||||
textFieldProps,
|
||||
containerProps,
|
||||
...props
|
||||
}: TextFieldProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1 }}>
|
||||
{label && (
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={styles.textFieldContainer} {...containerProps}>
|
||||
{label && <label tabIndex={0}>{label}</label>}
|
||||
|
||||
<div
|
||||
className={styles.textField({ focused: isFocused, theme })}
|
||||
|
@ -45,6 +48,8 @@ export function TextField({
|
|||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hint && <small tabIndex={0}>{hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,13 +3,13 @@ import type { PayloadAction } from "@reduxjs/toolkit";
|
|||
import type { TorrentProgress } from "@types";
|
||||
|
||||
interface DownloadState {
|
||||
packets: TorrentProgress[];
|
||||
lastPacket: TorrentProgress | null;
|
||||
gameId: number | null;
|
||||
gamesWithDeletionInProgress: number[];
|
||||
}
|
||||
|
||||
const initialState: DownloadState = {
|
||||
packets: [],
|
||||
lastPacket: null,
|
||||
gameId: null,
|
||||
gamesWithDeletionInProgress: [],
|
||||
};
|
||||
|
@ -18,12 +18,12 @@ export const downloadSlice = createSlice({
|
|||
name: "download",
|
||||
initialState,
|
||||
reducers: {
|
||||
addPacket: (state, action: PayloadAction<TorrentProgress>) => {
|
||||
state.packets = [...state.packets, action.payload];
|
||||
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId) state.gameId = action.payload.game.id;
|
||||
},
|
||||
clearDownload: (state) => {
|
||||
state.packets = [];
|
||||
state.lastPacket = null;
|
||||
state.gameId = null;
|
||||
},
|
||||
setGameDeleting: (state, action: PayloadAction<number>) => {
|
||||
|
@ -42,7 +42,7 @@ export const downloadSlice = createSlice({
|
|||
});
|
||||
|
||||
export const {
|
||||
addPacket,
|
||||
setLastPacket,
|
||||
clearDownload,
|
||||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
|
|
|
@ -21,7 +21,7 @@ export const getSteamLanguage = (language: string) => {
|
|||
if (language.startsWith("pt")) return "brazilian";
|
||||
if (language.startsWith("es")) return "spanish";
|
||||
if (language.startsWith("fr")) return "french";
|
||||
if (language.startsWith("ru")) return "russian";
|
||||
if (language.startsWith("ru") || language.startsWith("be")) return "russian";
|
||||
if (language.startsWith("it")) return "italian";
|
||||
if (language.startsWith("hu")) return "hungarian";
|
||||
if (language.startsWith("pl")) return "polish";
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./use-download";
|
||||
export * from "./use-library";
|
||||
export * from "./use-date";
|
||||
export * from "./redux";
|
||||
|
|
|
@ -1,15 +1,23 @@
|
|||
import { formatDistance } from "date-fns";
|
||||
import type { FormatDistanceOptions } from "date-fns";
|
||||
import { ptBR, enUS, es, fr } from "date-fns/locale";
|
||||
import { ptBR, enUS, es, fr, pl, hu, tr, ru, it, be } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useDate() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { language } = i18n;
|
||||
|
||||
const getDateLocale = () => {
|
||||
if (i18n.language.startsWith("pt")) return ptBR;
|
||||
if (i18n.language.startsWith("es")) return es;
|
||||
if (i18n.language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("hu")) return hu;
|
||||
if (language.startsWith("pl")) return pl;
|
||||
if (language.startsWith("tr")) return tr;
|
||||
if (language.startsWith("ru")) return ru;
|
||||
if (language.startsWith("it")) return it;
|
||||
if (language.startsWith("be")) return be;
|
||||
|
||||
return enUS;
|
||||
};
|
||||
|
|
|
@ -4,26 +4,24 @@ import { formatDownloadProgress } from "@renderer/helpers";
|
|||
import { useLibrary } from "./use-library";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import {
|
||||
addPacket,
|
||||
setLastPacket,
|
||||
clearDownload,
|
||||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
} from "@renderer/features";
|
||||
import type { GameShop, TorrentProgress } from "@types";
|
||||
import { useDate } from "./use-date";
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
|
||||
export function useDownload() {
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const { packets, gamesWithDeletionInProgress } = useAppSelector(
|
||||
const { lastPacket, gamesWithDeletionInProgress } = useAppSelector(
|
||||
(state) => state.download
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const lastPacket = packets.at(-1);
|
||||
|
||||
const startDownload = (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
|
@ -63,8 +61,8 @@ export function useDownload() {
|
|||
updateLibrary();
|
||||
});
|
||||
|
||||
const isVerifying = ["downloading_metadata", "checking_files"].includes(
|
||||
lastPacket?.game.status ?? ""
|
||||
const isVerifying = GameStatusHelper.isVerifying(
|
||||
lastPacket?.game.status ?? null
|
||||
);
|
||||
|
||||
const getETA = () => {
|
||||
|
@ -84,7 +82,7 @@ export function useDownload() {
|
|||
};
|
||||
|
||||
const getProgress = () => {
|
||||
if (lastPacket?.game.status === "checking_files") {
|
||||
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
|
||||
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
|
||||
}
|
||||
|
||||
|
@ -115,7 +113,6 @@ export function useDownload() {
|
|||
isVerifying,
|
||||
gameId: lastPacket?.game.id,
|
||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||
isDownloading: Boolean(lastPacket),
|
||||
progress: getProgress(),
|
||||
numPeers: lastPacket?.numPeers,
|
||||
numSeeds: lastPacket?.numSeeds,
|
||||
|
@ -128,6 +125,6 @@ export function useDownload() {
|
|||
deleteGame,
|
||||
isGameDeleting,
|
||||
clearDownload: () => dispatch(clearDownload()),
|
||||
addPacket: (packet: TorrentProgress) => dispatch(addPacket(packet)),
|
||||
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,12 +2,18 @@ import { SPACING_UNIT, vars } 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.bodyText,
|
||||
textAlign: "left",
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
":hover": {
|
||||
|
@ -15,6 +21,17 @@ export const downloadTitle = style({
|
|||
},
|
||||
});
|
||||
|
||||
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",
|
||||
alignSelf: "flex-start",
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
width: "100%",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteModal } from "./delete-modal";
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
|
||||
export function Downloads() {
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
|
@ -28,7 +28,6 @@ export function Downloads() {
|
|||
const {
|
||||
game: gameDownloading,
|
||||
progress,
|
||||
isDownloading,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
pauseDownload,
|
||||
|
@ -54,7 +53,7 @@ export function Downloads() {
|
|||
});
|
||||
|
||||
const getFinalDownloadSize = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
|
@ -65,8 +64,13 @@ export function Downloads() {
|
|||
return game.repack?.fileSize ?? "N/A";
|
||||
};
|
||||
|
||||
const downloaderName = {
|
||||
[Downloader.RealDebrid]: t("real_debrid"),
|
||||
[Downloader.Torrent]: t("torrent"),
|
||||
};
|
||||
|
||||
const getGameInfo = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
|
||||
if (isGameDeleting(game?.id)) {
|
||||
|
@ -78,7 +82,8 @@ export function Downloads() {
|
|||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
{gameDownloading?.status !== "downloading" ? (
|
||||
{gameDownloading?.status &&
|
||||
gameDownloading?.status !== GameStatus.Downloading ? (
|
||||
<p>{t(gameDownloading?.status)}</p>
|
||||
) : (
|
||||
<>
|
||||
|
@ -86,16 +91,18 @@ export function Downloads() {
|
|||
{formatBytes(gameDownloading?.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
<p>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
</p>
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
<p>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
if (GameStatusHelper.isReady(game?.status)) {
|
||||
return (
|
||||
<>
|
||||
<p>{game?.repack.title}</p>
|
||||
|
@ -103,12 +110,11 @@ export function Downloads() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "cancelled") return <p>{t("cancelled")}</p>;
|
||||
if (game?.status === "downloading_metadata")
|
||||
if (game?.status === GameStatus.Cancelled) return <p>{t("cancelled")}</p>;
|
||||
if (game?.status === GameStatus.DownloadingMetadata)
|
||||
return <p>{t("starting_download")}</p>;
|
||||
|
||||
if (game?.status === "paused") {
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
|
@ -126,7 +132,7 @@ export function Downloads() {
|
|||
};
|
||||
|
||||
const getGameActions = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
|
@ -143,7 +149,7 @@ export function Downloads() {
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => resumeDownload(game.id)} theme="outline">
|
||||
|
@ -156,7 +162,7 @@ export function Downloads() {
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
if (GameStatusHelper.isReady(game?.status)) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
@ -174,7 +180,7 @@ export function Downloads() {
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "downloading_metadata") {
|
||||
if (game?.status === GameStatus.DownloadingMetadata) {
|
||||
return (
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
|
@ -239,7 +245,7 @@ export function Downloads() {
|
|||
<li
|
||||
key={game.id}
|
||||
className={styles.download({
|
||||
cancelled: game.status === "cancelled",
|
||||
cancelled: game.status === GameStatus.Cancelled,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
|
@ -249,16 +255,21 @@ export function Downloads() {
|
|||
/>
|
||||
<div className={styles.downloadRightContent}>
|
||||
<div className={styles.downloadDetails}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
<div className={styles.downloadTitleWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<small className={styles.downloaderName}>
|
||||
{downloaderName[game?.downloader]}
|
||||
</small>
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
|
|
95
src/renderer/src/pages/game-details/gallery-slider.css.ts
Normal file
95
src/renderer/src/pages/game-details/gallery-slider.css.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const gallerySliderContainer = style({
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const gallerySliderMedia = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
flexGrow: "0",
|
||||
transition: "translate 0.3s ease-in-out",
|
||||
borderRadius: "4px",
|
||||
});
|
||||
|
||||
export const gallerySliderAnimationContainer = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderPreview = style({
|
||||
width: "100%",
|
||||
padding: `${SPACING_UNIT}px 0`,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
width: "20%",
|
||||
},
|
||||
"::-webkit-scrollbar": {
|
||||
height: "10px",
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderMediaPreview = style({
|
||||
cursor: "pointer",
|
||||
width: "20%",
|
||||
height: "20%",
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
opacity: 0.3,
|
||||
transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
":hover": {
|
||||
opacity: "1",
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderMediaPreviewActive = style({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const gallerySliderButton = style({
|
||||
all: "unset",
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
padding: "1rem",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-in-out",
|
||||
":hover": {
|
||||
backgroundColor: "rgb(0, 0, 0, 0.2)",
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderIcons = style({
|
||||
fill: vars.color.muted,
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
});
|
|
@ -1,15 +1,15 @@
|
|||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ShopDetails, SteamMovies, SteamScreenshot } from "@types";
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
|
||||
import * as styles from "./game-details.css";
|
||||
import * as styles from "./gallery-slider.css";
|
||||
|
||||
export interface GallerySliderProps {
|
||||
gameDetails: ShopDetails | null;
|
||||
}
|
||||
|
||||
export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
||||
const scrollContainerRef: RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [mediaCount] = useState<number>(() => {
|
||||
if (gameDetails) {
|
||||
if (gameDetails.screenshots && gameDetails.movies) {
|
||||
|
@ -20,21 +20,13 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
return gameDetails.screenshots.length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const [mediaIndex, setMediaIndex] = useState<number>(0);
|
||||
const [arrowShow, setArrowShow] = useState(false);
|
||||
|
||||
const scrollHorizontallyToPercentage = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const totalWidth = container.scrollWidth - container.clientWidth;
|
||||
const itemWidth = totalWidth / (mediaCount - 1);
|
||||
const scrollLeft = mediaIndex * itemWidth;
|
||||
container.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const showNextImage = () => {
|
||||
setMediaIndex((index: number) => {
|
||||
if (index === mediaCount - 1) return 0;
|
||||
|
@ -42,6 +34,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
return index + 1;
|
||||
});
|
||||
};
|
||||
|
||||
const showPrevImage = () => {
|
||||
setMediaIndex((index: number) => {
|
||||
if (index === 0) return mediaCount - 1;
|
||||
|
@ -51,11 +44,25 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollHorizontallyToPercentage();
|
||||
}, [mediaIndex]);
|
||||
setMediaIndex(0);
|
||||
}, [gameDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const totalWidth = container.scrollWidth - container.clientWidth;
|
||||
const itemWidth = totalWidth / (mediaCount - 1);
|
||||
const scrollLeft = mediaIndex * itemWidth;
|
||||
container.scrollLeft = scrollLeft;
|
||||
}
|
||||
}, [gameDetails, mediaIndex, mediaCount]);
|
||||
|
||||
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
|
||||
const hasMovies = gameDetails && gameDetails.movies?.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{gameDetails?.screenshots && (
|
||||
{hasScreenshots && (
|
||||
<div className={styles.gallerySliderContainer}>
|
||||
<div
|
||||
onMouseEnter={() => setArrowShow(true)}
|
||||
|
@ -65,33 +72,43 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
{gameDetails.movies &&
|
||||
gameDetails.movies.map((video: SteamMovies) => (
|
||||
<video
|
||||
key={video.id}
|
||||
controls
|
||||
className={styles.gallerySliderMedia}
|
||||
poster={video.thumbnail}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video.webm.max.replace("http", "https")} />
|
||||
</video>
|
||||
))}
|
||||
{gameDetails.screenshots &&
|
||||
gameDetails.screenshots.map((image: SteamScreenshot) => (
|
||||
<img
|
||||
className={styles.gallerySliderMedia}
|
||||
src={image.path_full}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
/>
|
||||
))}
|
||||
gameDetails.screenshots.map(
|
||||
(image: SteamScreenshot, i: number) => (
|
||||
<img
|
||||
key={"image-" + i}
|
||||
className={styles.gallerySliderMedia}
|
||||
src={image.path_full}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{arrowShow && (
|
||||
<>
|
||||
<button
|
||||
onClick={showPrevImage}
|
||||
type="button"
|
||||
className={styles.gallerySliderButton}
|
||||
style={{ left: 0 }}
|
||||
>
|
||||
<ChevronLeftIcon className={styles.gallerySliderIcons} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={showNextImage}
|
||||
type="button"
|
||||
className={styles.gallerySliderButton}
|
||||
style={{ right: 0 }}
|
||||
>
|
||||
|
@ -102,9 +119,10 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
</div>
|
||||
|
||||
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}>
|
||||
{gameDetails.movies &&
|
||||
gameDetails.movies.map((video: SteamMovies, i: number) => (
|
||||
{hasMovies &&
|
||||
gameDetails.movies?.map((video: SteamMovies, i: number) => (
|
||||
<img
|
||||
key={video.id}
|
||||
onClick={() => setMediaIndex(i)}
|
||||
src={video.thumbnail}
|
||||
className={`${styles.gallerySliderMediaPreview} ${mediaIndex === i ? styles.gallerySliderMediaPreviewActive : ""}`}
|
||||
|
@ -114,6 +132,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||
gameDetails.screenshots.map(
|
||||
(image: SteamScreenshot, i: number) => (
|
||||
<img
|
||||
key={"image-thumb-" + i}
|
||||
onClick={() =>
|
||||
setMediaIndex(
|
||||
i + (gameDetails.movies ? gameDetails.movies.length : 0)
|
||||
|
|
|
@ -79,94 +79,6 @@ export const descriptionContent = style({
|
|||
height: "100%",
|
||||
});
|
||||
|
||||
export const gallerySliderContainer = style({
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const gallerySliderMedia = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
transition: "translate 300ms ease-in-out",
|
||||
});
|
||||
|
||||
export const gallerySliderAnimationContainer = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderPreview = style({
|
||||
width: "100%",
|
||||
paddingTop: "0.5rem",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
width: "20%"
|
||||
}
|
||||
});
|
||||
|
||||
export const gallerySliderMediaPreview = style({
|
||||
cursor: "pointer",
|
||||
width: "20%",
|
||||
height: "20%",
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
opacity: 0.3,
|
||||
paddingRight: "5px",
|
||||
transition: "translate 300ms ease-in-out",
|
||||
":hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderMediaPreviewActive = style({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const gallerySliderButton = style({
|
||||
all: "unset",
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
padding: "1rem",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 100ms ease-in-out",
|
||||
":hover": {
|
||||
backgroundColor: "rgb(0,0,0, 0.2)",
|
||||
},
|
||||
});
|
||||
|
||||
export const gallerySliderIcons = style({
|
||||
stroke: "white",
|
||||
fill: "black",
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
});
|
||||
|
||||
export const contentSidebar = style({
|
||||
borderLeft: `solid 1px ${vars.color.border};`,
|
||||
width: "100%",
|
||||
|
|
|
@ -68,7 +68,7 @@ export function GameDetails() {
|
|||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
|
||||
const { game: gameDownloading, startDownload } = useDownload();
|
||||
|
||||
const heroImage = steamUrlBuilder.libraryHero(objectID!);
|
||||
|
||||
|
@ -122,7 +122,7 @@ export function GameDetails() {
|
|||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
}, [getGame, dispatch, navigate, objectID, i18n.language]);
|
||||
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isGameDownloading)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
|
@ -49,7 +50,7 @@ export function HeroPanelActions({
|
|||
filters: [
|
||||
{
|
||||
name: "Game executable",
|
||||
extensions: window.electron.platform === "win32" ? ["exe"] : [],
|
||||
extensions: ["exe"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -152,7 +153,7 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
@ -173,10 +174,13 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding" || (game && !game.status)) {
|
||||
if (
|
||||
GameStatusHelper.isReady(game?.status ?? null) ||
|
||||
(game && !game.status)
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{game?.status === "seeding" ? (
|
||||
{GameStatusHelper.isReady(game?.status ?? null) ? (
|
||||
<Button
|
||||
onClick={openGameInstaller}
|
||||
theme="outline"
|
||||
|
@ -212,7 +216,7 @@ export function HeroPanelActions({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "cancelled") {
|
||||
if (game?.status === GameStatus.Cancelled) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Game } from "@types";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
export interface HeroPanelPlaytimeProps {
|
||||
game: Game;
|
||||
isGamePlaying: boolean;
|
||||
}
|
||||
|
||||
export function HeroPanelPlaytime({
|
||||
game,
|
||||
isGamePlaying,
|
||||
}: HeroPanelPlaytimeProps) {
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { i18n, t } = useTranslation("game_details");
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.lastTimePlayed) {
|
||||
setLastTimePlayed(
|
||||
formatDistance(game.lastTimePlayed, new Date(), {
|
||||
addSuffix: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [game?.lastTimePlayed, formatDistance]);
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
const formatPlayTime = () => {
|
||||
const milliseconds = game?.playTimeInMilliseconds || 0;
|
||||
const seconds = milliseconds / 1000;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
};
|
||||
|
||||
if (!game.lastTimePlayed) {
|
||||
return <p>{t("not_played_yet", { title: game.title })}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{t("play_time", {
|
||||
amount: formatPlayTime(),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{isGamePlaying ? (
|
||||
<p>{t("playing_now")}</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("last_time_played", {
|
||||
period: lastTimePlayed,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,17 +1,17 @@
|
|||
import { format } from "date-fns";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
import type { Game, ShopDetails } from "@types";
|
||||
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useDate } from "@renderer/hooks/use-date";
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { HeroPanelActions } from "./hero-panel-actions";
|
||||
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
||||
|
||||
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
game: Game | null;
|
||||
|
@ -22,8 +22,6 @@ export interface HeroPanelProps {
|
|||
getGame: () => void;
|
||||
}
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
export function HeroPanel({
|
||||
game,
|
||||
gameDetails,
|
||||
|
@ -32,54 +30,22 @@ export function HeroPanel({
|
|||
getGame,
|
||||
isGamePlaying,
|
||||
}: HeroPanelProps) {
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
isDownloading,
|
||||
progress,
|
||||
eta,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.lastTimePlayed) {
|
||||
setLastTimePlayed(
|
||||
formatDistance(game.lastTimePlayed, new Date(), {
|
||||
addSuffix: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [game?.lastTimePlayed, formatDistance]);
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}, [i18n]);
|
||||
|
||||
const formatPlayTime = () => {
|
||||
const milliseconds = game?.playTimeInMilliseconds || 0;
|
||||
const seconds = milliseconds / 1000;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
};
|
||||
const isGameDownloading =
|
||||
gameDownloading?.id === game?.id &&
|
||||
GameStatusHelper.isDownloading(game?.status ?? null);
|
||||
|
||||
const finalDownloadSize = useMemo(() => {
|
||||
if (!game) return "N/A";
|
||||
|
@ -106,7 +72,7 @@ export function HeroPanel({
|
|||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
</p>
|
||||
|
||||
{gameDownloading.status !== "downloading" ? (
|
||||
{gameDownloading.status !== GameStatus.Downloading ? (
|
||||
<>
|
||||
<p>{t(gameDownloading.status)}</p>
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
|
@ -116,7 +82,8 @@ export function HeroPanel({
|
|||
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
<small>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
{game?.downloader === Downloader.Torrent &&
|
||||
`${numPeers} peers / ${numSeeds} seeds`}
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
|
@ -124,7 +91,7 @@ export function HeroPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
if (game?.status === GameStatus.Paused) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
|
@ -139,30 +106,8 @@ export function HeroPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding" || (game && !game.status)) {
|
||||
if (!game.lastTimePlayed) {
|
||||
return <p>{t("not_played_yet", { title: game.title })}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{t("play_time", {
|
||||
amount: formatPlayTime(),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{isGamePlaying ? (
|
||||
<p>{t("playing_now")}</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("last_time_played", {
|
||||
period: lastTimePlayed,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (game && GameStatusHelper.isReady(game?.status ?? GameStatus.Finished)) {
|
||||
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
|
||||
}
|
||||
|
||||
const [latestRepack] = gameDetails.repacks;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export const DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY =
|
||||
"dontShowOnlineFixInstructions";
|
||||
|
||||
export const DONT_SHOW_DODI_INSTRUCTIONS_KEY = "dontShowDodiInstructions";
|
||||
|
|
|
@ -43,7 +43,7 @@ export function RepacksModal({
|
|||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(gameDetails.repacks);
|
||||
}, [gameDetails.repacks]);
|
||||
}, [gameDetails.repacks, visible]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
|
|
|
@ -17,11 +17,3 @@ export const hintText = style({
|
|||
fontSize: "12px",
|
||||
color: vars.color.bodyText,
|
||||
});
|
||||
|
||||
export const settingsLink = style({
|
||||
textDecoration: "none",
|
||||
color: "#C0C1C7",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import { GameRepack, ShopDetails } from "@types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import { DiskSpace } from "check-disk-space";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as styles from "./select-folder-modal.css";
|
||||
import { DownloadIcon } from "@primer/octicons-react";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
export interface SelectFolderModalProps {
|
||||
visible: boolean;
|
||||
|
@ -75,7 +74,7 @@ export function SelectFolderModal({
|
|||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={`${gameDetails.name} Installation folder`}
|
||||
title={t("installation_folder", { name: gameDetails.name })}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
|
@ -100,10 +99,9 @@ export function SelectFolderModal({
|
|||
</Button>
|
||||
</div>
|
||||
<p className={styles.hintText}>
|
||||
{t("select_folder_hint")}{" "}
|
||||
<Link to="/settings" className={styles.settingsLink}>
|
||||
{t("settings")}
|
||||
</Link>
|
||||
<Trans i18nKey="select_folder_hint" ns="game_details">
|
||||
<Link to="/settings" />
|
||||
</Trans>
|
||||
</p>
|
||||
<Button onClick={handleStartClick} disabled={downloadStarting}>
|
||||
<DownloadIcon />
|
||||
|
|
60
src/renderer/src/pages/settings/settings-behavior.tsx
Normal file
60
src/renderer/src/pages/settings/settings-behavior.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { UserPreferences } from "@types";
|
||||
|
||||
import { CheckboxField } from "@renderer/components";
|
||||
|
||||
export interface SettingsBehaviorProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsBehavior({
|
||||
updateUserPreferences,
|
||||
userPreferences,
|
||||
}: SettingsBehaviorProps) {
|
||||
const [form, setForm] = useState({
|
||||
preferQuitInsteadOfHiding: false,
|
||||
runAtStartup: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding,
|
||||
runAtStartup: userPreferences.runAtStartup,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleChange = (values: Partial<typeof form>) => {
|
||||
setForm((prev) => ({ ...prev, ...values }));
|
||||
updateUserPreferences(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckboxField
|
||||
label={t("quit_app_instead_hiding")}
|
||||
checked={form.preferQuitInsteadOfHiding}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
preferQuitInsteadOfHiding: !form.preferQuitInsteadOfHiding,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("launch_with_system")}
|
||||
onChange={() => {
|
||||
handleChange({ runAtStartup: !form.runAtStartup });
|
||||
window.electron.autoLaunch(!form.runAtStartup);
|
||||
}}
|
||||
checked={form.runAtStartup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
7
src/renderer/src/pages/settings/settings-general.css.ts
Normal file
7
src/renderer/src/pages/settings/settings-general.css.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
|
||||
export const downloadsPathField = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
120
src/renderer/src/pages/settings/settings-general.tsx
Normal file
120
src/renderer/src/pages/settings/settings-general.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TextField, Button, CheckboxField } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-general.css";
|
||||
import type { UserPreferences } from "@types";
|
||||
|
||||
export interface SettingsGeneralProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsGeneral({
|
||||
userPreferences,
|
||||
updateUserPreferences,
|
||||
}: SettingsGeneralProps) {
|
||||
const [form, setForm] = useState({
|
||||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
repackUpdatesNotificationsEnabled: false,
|
||||
telemetryEnabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
const {
|
||||
downloadsPath,
|
||||
downloadNotificationsEnabled,
|
||||
repackUpdatesNotificationsEnabled,
|
||||
telemetryEnabled,
|
||||
} = userPreferences;
|
||||
|
||||
window.electron.getDefaultDownloadsPath().then((defaultDownloadsPath) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
downloadsPath: downloadsPath ?? defaultDownloadsPath,
|
||||
downloadNotificationsEnabled,
|
||||
repackUpdatesNotificationsEnabled,
|
||||
telemetryEnabled,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: form.downloadsPath,
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
updateUserPreferences({ downloadsPath: path });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (values: Partial<typeof form>) => {
|
||||
setForm((prev) => ({ ...prev, ...values }));
|
||||
updateUserPreferences(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
label={t("downloads_path")}
|
||||
value={form.downloadsPath}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3>{t("notifications")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_download_notifications")}
|
||||
checked={form.downloadNotificationsEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
downloadNotificationsEnabled: !form.downloadNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_repack_list_notifications")}
|
||||
checked={form.repackUpdatesNotificationsEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
repackUpdatesNotificationsEnabled:
|
||||
!form.repackUpdatesNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<h3>{t("telemetry")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("telemetry_description")}
|
||||
checked={form.telemetryEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
telemetryEnabled: !form.telemetryEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
|
||||
export const form = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
85
src/renderer/src/pages/settings/settings-real-debrid.tsx
Normal file
85
src/renderer/src/pages/settings/settings-real-debrid.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import * as styles from "./settings-real-debrid.css";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
|
||||
|
||||
export interface SettingsRealDebridProps {
|
||||
userPreferences: UserPreferences | null;
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => void;
|
||||
}
|
||||
|
||||
export function SettingsRealDebrid({
|
||||
userPreferences,
|
||||
updateUserPreferences,
|
||||
}: SettingsRealDebridProps) {
|
||||
const [form, setForm] = useState({
|
||||
useRealDebrid: false,
|
||||
realDebridApiToken: null as string | null,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useRealDebrid: Boolean(userPreferences.realDebridApiToken),
|
||||
realDebridApiToken: userPreferences.realDebridApiToken ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
updateUserPreferences({
|
||||
realDebridApiToken: form.useRealDebrid ? form.realDebridApiToken : null,
|
||||
});
|
||||
};
|
||||
|
||||
const isButtonDisabled = form.useRealDebrid && !form.realDebridApiToken;
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleFormSubmit}>
|
||||
<CheckboxField
|
||||
label={t("enable_real_debrid")}
|
||||
checked={form.useRealDebrid}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useRealDebrid: !form.useRealDebrid,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useRealDebrid && (
|
||||
<TextField
|
||||
label={t("real_debrid_api_token_description")}
|
||||
value={form.realDebridApiToken ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, realDebridApiToken: event.target.value })
|
||||
}
|
||||
placeholder="API Token"
|
||||
containerProps={{ style: { marginTop: `${SPACING_UNIT}px` } }}
|
||||
hint={
|
||||
<Trans i18nKey="real_debrid_api_token_hint" ns="settings">
|
||||
<Link to={REAL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -20,7 +20,7 @@ export const content = style({
|
|||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const downloadsPathField = style({
|
||||
export const settingsCategories = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
|
|
@ -1,139 +1,76 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Button, CheckboxField, TextField } from "@renderer/components";
|
||||
import { Button } from "@renderer/components";
|
||||
|
||||
import * as styles from "./settings.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserPreferences } from "@types";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
|
||||
const categories = ["general", "behavior", "real_debrid"];
|
||||
|
||||
export function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
repackUpdatesNotificationsEnabled: false,
|
||||
telemetryEnabled: false,
|
||||
preferQuitInsteadOfHiding: false,
|
||||
runAtStartup: false,
|
||||
});
|
||||
const [currentCategory, setCurrentCategory] = useState(categories.at(0)!);
|
||||
const [userPreferences, setUserPreferences] =
|
||||
useState<UserPreferences | null>(null);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
window.electron.getDefaultDownloadsPath(),
|
||||
window.electron.getUserPreferences(),
|
||||
]).then(([path, userPreferences]) => {
|
||||
setForm({
|
||||
downloadsPath: userPreferences?.downloadsPath || path,
|
||||
downloadNotificationsEnabled:
|
||||
userPreferences?.downloadNotificationsEnabled ?? false,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences?.repackUpdatesNotificationsEnabled ?? false,
|
||||
telemetryEnabled: userPreferences?.telemetryEnabled ?? false,
|
||||
preferQuitInsteadOfHiding:
|
||||
userPreferences?.preferQuitInsteadOfHiding ?? false,
|
||||
runAtStartup: userPreferences?.runAtStartup ?? false,
|
||||
});
|
||||
window.electron.getUserPreferences().then((userPreferences) => {
|
||||
setUserPreferences(userPreferences);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateUserPreferences = <T extends keyof UserPreferences>(
|
||||
field: T,
|
||||
value: UserPreferences[T]
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
window.electron.updateUserPreferences({
|
||||
[field]: value,
|
||||
});
|
||||
const handleUpdateUserPreferences = (values: Partial<UserPreferences>) => {
|
||||
window.electron.updateUserPreferences(values);
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: form.downloadsPath,
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
updateUserPreferences("downloadsPath", path);
|
||||
const renderCategory = () => {
|
||||
if (currentCategory === "general") {
|
||||
return (
|
||||
<SettingsGeneral
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCategory === "real_debrid") {
|
||||
return (
|
||||
<SettingsRealDebrid
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsBehavior
|
||||
userPreferences={userPreferences}
|
||||
updateUserPreferences={handleUpdateUserPreferences}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
label={t("downloads_path")}
|
||||
value={form.downloadsPath}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<section className={styles.settingsCategories}>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
theme={currentCategory === category ? "primary" : "outline"}
|
||||
onClick={() => setCurrentCategory(category)}
|
||||
>
|
||||
{t(category)}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3>{t("notifications")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_download_notifications")}
|
||||
checked={form.downloadNotificationsEnabled}
|
||||
onChange={() =>
|
||||
updateUserPreferences(
|
||||
"downloadNotificationsEnabled",
|
||||
!form.downloadNotificationsEnabled
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_repack_list_notifications")}
|
||||
checked={form.repackUpdatesNotificationsEnabled}
|
||||
onChange={() =>
|
||||
updateUserPreferences(
|
||||
"repackUpdatesNotificationsEnabled",
|
||||
!form.repackUpdatesNotificationsEnabled
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<h3>{t("telemetry")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("telemetry_description")}
|
||||
checked={form.telemetryEnabled}
|
||||
onChange={() =>
|
||||
updateUserPreferences("telemetryEnabled", !form.telemetryEnabled)
|
||||
}
|
||||
/>
|
||||
|
||||
<h3>{t("behavior")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("quit_app_instead_hiding")}
|
||||
checked={form.preferQuitInsteadOfHiding}
|
||||
onChange={() =>
|
||||
updateUserPreferences(
|
||||
"preferQuitInsteadOfHiding",
|
||||
!form.preferQuitInsteadOfHiding
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("launch_with_system")}
|
||||
onChange={() => {
|
||||
updateUserPreferences("runAtStartup", !form.runAtStartup);
|
||||
window.electron.autoLaunch(!form.runAtStartup);
|
||||
}}
|
||||
checked={form.runAtStartup}
|
||||
/>
|
||||
<h2>{t(currentCategory)}</h2>
|
||||
{renderCategory()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (!Number.isFinite(bytes) || isNaN(bytes) || bytes <= 0) {
|
||||
return `0 ${FORMAT[0]}`;
|
||||
}
|
||||
|
||||
const byteKBase = 1024;
|
||||
|
||||
const base = Math.floor(Math.log(bytes) / Math.log(byteKBase));
|
||||
|
||||
const formatedByte = bytes / byteKBase ** base;
|
||||
|
||||
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export * from "./format-bytes";
|
Loading…
Add table
Add a link
Reference in a new issue