Merge branch 'main' into feature/better-repack-modal

This commit is contained in:
ChristoferMendes 2024-05-12 14:23:22 -03:00
commit dce700b189
100 changed files with 2762 additions and 1267 deletions

View file

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

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="#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

View 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

View file

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

Before After
Before After

View file

@ -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} &quot;{VERSION_CODENAME}&quot;
</small>
</footer>

View file

@ -19,6 +19,7 @@ const base = style({
":disabled": {
opacity: vars.opacity.disabled,
pointerEvents: "none",
cursor: "not-allowed",
},
});

View file

@ -17,9 +17,9 @@ export function Button({
}: ButtonProps) {
return (
<button
{...props}
type="button"
className={cn(styles.button[theme], className)}
{...props}
>
{children}
</button>

View file

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

View file

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

View file

@ -0,0 +1,9 @@
import { style } from "@vanilla-extract/css";
export const link = style({
textDecoration: "none",
color: "#C0C1C7",
":hover": {
textDecoration: "underline",
},
});

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
export const DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY =
"dontShowOnlineFixInstructions";
export const DONT_SHOW_DODI_INSTRUCTIONS_KEY = "dontShowDodiInstructions";

View file

@ -43,7 +43,7 @@ export function RepacksModal({
useEffect(() => {
setFilteredRepacks(gameDetails.repacks);
}, [gameDetails.repacks]);
}, [gameDetails.repacks, visible]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);

View file

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

View file

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

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

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

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

View file

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

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

View file

@ -20,7 +20,7 @@ export const content = style({
flexDirection: "column",
});
export const downloadsPathField = style({
export const settingsCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View file

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

View file

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

View file

@ -1 +0,0 @@
export * from "./format-bytes";