mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-20 17:53:55 +00:00
resolved conflicts and prettier
This commit is contained in:
@@ -123,7 +123,7 @@ export const titleBar = style({
|
||||
alignItems: "center",
|
||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
||||
WebkitAppRegion: "drag",
|
||||
zIndex: "4",
|
||||
zIndex: vars.zIndex.titleBar,
|
||||
borderBottom: `1px solid ${vars.color.border}`,
|
||||
} as ComplexStyleRule);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import { downloadSourcesWorker } from "./workers";
|
||||
import { downloadSourcesTable } from "./dexie";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
import { SPACING_UNIT } from "./theme.css";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -84,7 +85,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onDownloadProgress(
|
||||
(downloadProgress) => {
|
||||
if (downloadProgress.game.progress === 1) {
|
||||
if (downloadProgress.progress === 1) {
|
||||
clearDownload();
|
||||
updateLibrary();
|
||||
return;
|
||||
@@ -212,27 +213,43 @@ export function App() {
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
channel.onmessage = async (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
updateRepacks();
|
||||
|
||||
downloadSourcesTable.toArray().then((downloadSources) => {
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.forEach((downloadSource) => {
|
||||
window.electron
|
||||
.putDownloadSource(downloadSource.objectIds)
|
||||
.then(({ fingerprint }) => {
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
});
|
||||
});
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.forEach(async (downloadSource) => {
|
||||
const { fingerprint } = await window.electron.putDownloadSource(
|
||||
downloadSource.objectIds
|
||||
);
|
||||
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}, [updateRepacks]);
|
||||
|
||||
const playAudio = useCallback(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.2;
|
||||
audio.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(() => {
|
||||
playAudio();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [playAudio]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
}, [dispatch]);
|
||||
@@ -250,12 +267,24 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: `${26 + SPACING_UNIT * 2}px`,
|
||||
right: "16px",
|
||||
maxWidth: "420px",
|
||||
width: "420px",
|
||||
}}
|
||||
>
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
title={toast.title}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleToastClose}
|
||||
duration={toast.duration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HydraCloudModal
|
||||
visible={isHydraCloudModalVisible}
|
||||
|
||||
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
Binary file not shown.
BIN
src/renderer/src/assets/icons/torbox.webp
Normal file
BIN
src/renderer/src/assets/icons/torbox.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -21,4 +21,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__version-button {
|
||||
color: globals.$body-color;
|
||||
border-bottom: solid 1px transparent;
|
||||
|
||||
&:hover {
|
||||
border-bottom: solid 1px globals.$body-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload, useUserDetails } from "@renderer/hooks";
|
||||
import { useDownload, useLibrary, useUserDetails } from "@renderer/hooks";
|
||||
|
||||
import "./bottom-panel.scss";
|
||||
|
||||
@@ -15,9 +15,11 @@ export function BottomPanel() {
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const { library } = useLibrary();
|
||||
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const isGameDownloading = !!lastPacket?.game;
|
||||
const isGameDownloading = !!lastPacket;
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
const [sessionHash, setSessionHash] = useState<null | string>("");
|
||||
@@ -32,27 +34,29 @@ export function BottomPanel() {
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isGameDownloading) {
|
||||
const game = library.find((game) => game.id === lastPacket?.gameId)!;
|
||||
|
||||
if (lastPacket?.isCheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: lastPacket?.game.title,
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
||||
if (lastPacket?.isDownloadingMetadata)
|
||||
return t("downloading_metadata", {
|
||||
title: lastPacket?.game.title,
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
||||
if (!eta) {
|
||||
return t("calculating_eta", {
|
||||
title: lastPacket?.game.title,
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
}
|
||||
|
||||
return t("downloading", {
|
||||
title: lastPacket?.game.title,
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
eta,
|
||||
speed: downloadSpeed,
|
||||
@@ -60,16 +64,7 @@ export function BottomPanel() {
|
||||
}
|
||||
|
||||
return t("no_downloads_in_progress");
|
||||
}, [
|
||||
t,
|
||||
isGameDownloading,
|
||||
lastPacket?.game,
|
||||
lastPacket?.isDownloadingMetadata,
|
||||
lastPacket?.isCheckingFiles,
|
||||
progress,
|
||||
eta,
|
||||
downloadSpeed,
|
||||
]);
|
||||
}, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]);
|
||||
|
||||
return (
|
||||
<footer className="bottom-panel">
|
||||
@@ -81,10 +76,15 @@ export function BottomPanel() {
|
||||
<small>{status}</small>
|
||||
</button>
|
||||
|
||||
<small>
|
||||
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
||||
{VERSION_CODENAME}"
|
||||
</small>
|
||||
<button
|
||||
data-featurebase-changelog
|
||||
className="bottom-panel__version-button"
|
||||
>
|
||||
<small data-featurebase-changelog>
|
||||
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
||||
{VERSION_CODENAME}"
|
||||
</small>
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DropdownMenu({
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
}: DropdownMenuProps) {
|
||||
}: Readonly<DropdownMenuProps>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function Modal({
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
const openModals = document.querySelectorAll("[role=dialog]");
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const sidebar = recipe({
|
||||
base: {
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
color: vars.color.muted,
|
||||
flexDirection: "column",
|
||||
display: "flex",
|
||||
transition: "opacity ease 0.2s",
|
||||
borderRight: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
variants: {
|
||||
resizing: {
|
||||
true: {
|
||||
opacity: vars.opacity.active,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
darwin: {
|
||||
true: {
|
||||
paddingTop: `${SPACING_UNIT * 6}px`,
|
||||
},
|
||||
false: {
|
||||
paddingTop: `${SPACING_UNIT}px`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
});
|
||||
|
||||
export const handle = style({
|
||||
width: "5px",
|
||||
height: "100%",
|
||||
cursor: "col-resize",
|
||||
position: "absolute",
|
||||
right: "0",
|
||||
});
|
||||
|
||||
export const menu = style({
|
||||
listStyle: "none",
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const menuItem = recipe({
|
||||
base: {
|
||||
transition: "all ease 0.1s",
|
||||
cursor: "pointer",
|
||||
textWrap: "nowrap",
|
||||
display: "flex",
|
||||
color: vars.color.muted,
|
||||
borderRadius: "4px",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
true: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
},
|
||||
muted: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
":hover": {
|
||||
opacity: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const menuItemButton = style({
|
||||
color: "inherit",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
padding: `9px ${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const menuItemButtonLabel = style({
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const gameIcon = style({
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
minWidth: "20px",
|
||||
minHeight: "20px",
|
||||
borderRadius: "4px",
|
||||
backgroundSize: "cover",
|
||||
});
|
||||
|
||||
export const sectionTitle = style({
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const helpButton = style({
|
||||
color: vars.color.muted,
|
||||
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
|
||||
gap: "9px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
borderTop: `solid 1px ${vars.color.border}`,
|
||||
transition: "background-color ease 0.1s",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const helpButtonIcon = style({
|
||||
background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
borderRadius: "50%",
|
||||
});
|
||||
136
src/renderer/src/components/sidebar/sidebar.scss
Normal file
136
src/renderer/src/components/sidebar/sidebar.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.sidebar {
|
||||
background-color: globals.$dark-background-color;
|
||||
color: globals.$muted-color;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
transition: opacity ease 0.2s;
|
||||
border-right: solid 1px globals.$border-color;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-top: globals.$spacing-unit;
|
||||
|
||||
&--resizing {
|
||||
opacity: globals.$active-opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--darwin {
|
||||
padding-top: calc(globals.$spacing-unit * 6);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&__menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__menu-item {
|
||||
transition: all ease 0.1s;
|
||||
cursor: pointer;
|
||||
text-wrap: nowrap;
|
||||
display: flex;
|
||||
color: globals.$muted-color;
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&--muted {
|
||||
opacity: globals.$disabled-opacity;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item-button {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 9px globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__menu-item-button-label {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__game-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__section {
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__help-button {
|
||||
color: globals.$muted-color;
|
||||
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
|
||||
gap: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-top: solid 1px globals.$border-color;
|
||||
transition: background-color ease 0.1s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&__help-button-icon {
|
||||
background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
|
||||
import { routes } from "./routes";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import "./sidebar.scss";
|
||||
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import cn from "classnames";
|
||||
import { CommentDiscussionIcon } from "@primer/octicons-react";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
@@ -56,7 +58,7 @@ export function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.game.id, updateLibrary]);
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
|
||||
@@ -118,18 +120,17 @@ export function Sidebar() {
|
||||
}, [isResizing]);
|
||||
|
||||
const getGameTitle = (game: LibraryGame) => {
|
||||
if (lastPacket?.game.id === game.id) {
|
||||
if (lastPacket?.gameId === game.id) {
|
||||
return t("downloading", {
|
||||
title: game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (game.downloadQueue !== null) {
|
||||
return t("queued", { title: game.title });
|
||||
}
|
||||
if (game.download?.queued) return t("queued", { title: game.title });
|
||||
|
||||
if (game.status === "paused") return t("paused", { title: game.title });
|
||||
if (game.download?.status === "paused")
|
||||
return t("paused", { title: game.title });
|
||||
|
||||
return game.title;
|
||||
};
|
||||
@@ -146,7 +147,7 @@ export function Sidebar() {
|
||||
) => {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectID,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
@@ -155,7 +156,8 @@ export function Sidebar() {
|
||||
if (event.detail === 2) {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
game.shop,
|
||||
game.objectId,
|
||||
game.executablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
@@ -168,9 +170,9 @@ export function Sidebar() {
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={styles.sidebar({
|
||||
resizing: isResizing,
|
||||
darwin: window.electron.platform === "darwin",
|
||||
className={cn("sidebar", {
|
||||
"sidebar--resizing": isResizing,
|
||||
"sidebar--darwin": window.electron.platform === "darwin",
|
||||
})}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
@@ -179,23 +181,28 @@ export function Sidebar() {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<SidebarProfile />
|
||||
|
||||
<div className={styles.content}>
|
||||
<section className={styles.section}>
|
||||
<ul className={styles.menu}>
|
||||
<div className="sidebar__content">
|
||||
<section className="sidebar__section">
|
||||
<ul className="sidebar__menu">
|
||||
{routes.map(({ nameKey, path, render }) => (
|
||||
<li
|
||||
key={nameKey}
|
||||
className={styles.menuItem({
|
||||
active: location.pathname === path,
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active": location.pathname === path,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
{render()}
|
||||
@@ -206,8 +213,8 @@ export function Sidebar() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("my_library")}</small>
|
||||
|
||||
<TextField
|
||||
ref={filterRef}
|
||||
@@ -216,34 +223,35 @@ export function Sidebar() {
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
<ul className={styles.menu}>
|
||||
<ul className="sidebar__menu">
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.menuItem({
|
||||
active:
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname ===
|
||||
`/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "removed",
|
||||
`/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted":
|
||||
game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.gameIcon}
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.gameIcon} />
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
|
||||
<span className={styles.menuItemButtonLabel}>
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -257,10 +265,10 @@ export function Sidebar() {
|
||||
{hasActiveSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.helpButton}
|
||||
className="sidebar__help-button"
|
||||
data-open-support-chat
|
||||
>
|
||||
<div className={styles.helpButtonIcon}>
|
||||
<div className="sidebar__help-button-icon">
|
||||
<CommentDiscussionIcon size={14} />
|
||||
</div>
|
||||
<span>{t("need_help")}</span>
|
||||
@@ -269,7 +277,7 @@ export function Sidebar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.handle}
|
||||
className="sidebar__handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -14,7 +14,7 @@ export const textField = recipe({
|
||||
base: {
|
||||
display: "inline-flex",
|
||||
transition: "all ease 0.2s",
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
borderRadius: "8px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
const TOAST_HEIGHT = 80;
|
||||
|
||||
export const slideIn = keyframes({
|
||||
"0%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
export const slideOut = keyframes({
|
||||
"0%": { transform: `translateY(0)` },
|
||||
"100%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
|
||||
});
|
||||
|
||||
export const toast = recipe({
|
||||
base: {
|
||||
animationDuration: "0.2s",
|
||||
animationTimingFunction: "ease-in-out",
|
||||
maxHeight: TOAST_HEIGHT,
|
||||
position: "fixed",
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
right: `${SPACING_UNIT * 2}px`,
|
||||
/* Bottom panel height + 16px */
|
||||
bottom: `${26 + SPACING_UNIT * 2}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
zIndex: vars.zIndex.toast,
|
||||
maxWidth: "500px",
|
||||
},
|
||||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: slideOut,
|
||||
transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)`,
|
||||
},
|
||||
false: {
|
||||
animationName: slideIn,
|
||||
transform: `translateY(0)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const toastContent = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const progress = style({
|
||||
width: "100%",
|
||||
height: "5px",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
export const closeButton = style({
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
});
|
||||
|
||||
export const successIcon = style({
|
||||
color: vars.color.success,
|
||||
});
|
||||
|
||||
export const errorIcon = style({
|
||||
color: vars.color.danger,
|
||||
});
|
||||
|
||||
export const warningIcon = style({
|
||||
color: vars.color.warning,
|
||||
});
|
||||
85
src/renderer/src/components/toast/toast.scss
Normal file
85
src/renderer/src/components/toast/toast.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.toast {
|
||||
animation-duration: 0.2s;
|
||||
animation-timing-function: ease-in-out;
|
||||
position: absolute;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
z-index: globals.$toast-z-index;
|
||||
max-width: 420px;
|
||||
animation-name: enter;
|
||||
transform: translateY(0);
|
||||
|
||||
&--closing {
|
||||
animation-name: exit;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: globals.$dark-background-color;
|
||||
}
|
||||
&::-webkit-progress-value {
|
||||
background-color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
&--success {
|
||||
color: globals.$success-color;
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: globals.$danger-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
color: globals.$warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,28 @@ import {
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import * as styles from "./toast.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import "./toast.scss";
|
||||
import cn from "classnames";
|
||||
|
||||
export interface ToastProps {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
type: "success" | "error" | "warning";
|
||||
duration?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const INITIAL_PROGRESS = 100;
|
||||
|
||||
export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
export function Toast({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
duration = 2500,
|
||||
onClose,
|
||||
}: Readonly<ToastProps>) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [progress, setProgress] = useState(INITIAL_PROGRESS);
|
||||
|
||||
@@ -31,7 +40,7 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
|
||||
closingAnimation.current = requestAnimationFrame(
|
||||
function animateClosing(time) {
|
||||
if (time - zero <= 200) {
|
||||
if (time - zero <= 150) {
|
||||
closingAnimation.current = requestAnimationFrame(animateClosing);
|
||||
} else {
|
||||
onClose();
|
||||
@@ -43,17 +52,13 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const zero = performance.now();
|
||||
|
||||
progressAnimation.current = requestAnimationFrame(
|
||||
function animateProgress(time) {
|
||||
const elapsed = time - zero;
|
||||
|
||||
const progress = Math.min(elapsed / 2500, 1);
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const currentValue =
|
||||
INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress;
|
||||
|
||||
setProgress(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
progressAnimation.current = requestAnimationFrame(animateProgress);
|
||||
} else {
|
||||
@@ -70,37 +75,62 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
setIsClosing(false);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [startAnimateClosing, visible]);
|
||||
}, [startAnimateClosing, duration, visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.toast({ closing: isClosing })}>
|
||||
<div className={styles.toastContent}>
|
||||
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className={styles.successIcon} />
|
||||
)}
|
||||
|
||||
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
|
||||
|
||||
{type === "warning" && <AlertIcon className={styles.warningIcon} />}
|
||||
<span style={{ fontWeight: "bold" }}>{message}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={startAnimateClosing}
|
||||
aria-label="Close toast"
|
||||
<div
|
||||
className={cn("toast", {
|
||||
"toast--closing": isClosing,
|
||||
})}
|
||||
>
|
||||
<div className="toast__content">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `8px`,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: `8px`,
|
||||
}}
|
||||
>
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className="toast__icon--success" />
|
||||
)}
|
||||
|
||||
{type === "error" && (
|
||||
<XCircleFillIcon className="toast__icon--error" />
|
||||
)}
|
||||
|
||||
{type === "warning" && (
|
||||
<AlertIcon className="toast__icon--warning" />
|
||||
)}
|
||||
|
||||
<span style={{ fontWeight: "bold", flex: 1 }}>{title}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="toast__close-button"
|
||||
onClick={startAnimateClosing}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && <p>{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<progress className={styles.progress} value={progress} max={100} />
|
||||
<progress className="toast__progress" value={progress} max={100} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.PixelDrain]: "PixelDrain",
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameShop,
|
||||
GameStats,
|
||||
LibraryGame,
|
||||
ShopDetails,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
@@ -68,12 +68,12 @@ export function GameDetailsContextProvider({
|
||||
objectId,
|
||||
gameTitle,
|
||||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
}: Readonly<GameDetailsContextProps>) {
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [achievements, setAchievements] = useState<UserAchievement[] | null>(
|
||||
null
|
||||
);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [game, setGame] = useState<LibraryGame | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -81,7 +81,7 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [gameColor, setGameColor] = useState("");
|
||||
const [isGameRunning, setisGameRunning] = useState(false);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
@@ -101,15 +101,16 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectId(objectId!)
|
||||
.getGameByObjectId(shop, objectId)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectId]);
|
||||
}, [setGame, shop, objectId]);
|
||||
|
||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||
const isGameDownloading =
|
||||
lastPacket?.gameId === game?.id && game?.download?.status === "active";
|
||||
|
||||
useEffect(() => {
|
||||
updateGame();
|
||||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
}, [updateGame, isGameDownloading, lastPacket?.gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
@@ -167,7 +168,7 @@ export function GameDetailsContextProvider({
|
||||
setShopDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
setIsGameRunning(false);
|
||||
setAchievements(null);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
@@ -182,17 +183,18 @@ export function GameDetailsContextProvider({
|
||||
updateGame();
|
||||
}
|
||||
|
||||
setisGameRunning(updatedIsGameRunning);
|
||||
setIsGameRunning(updatedIsGameRunning);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
const lastDownloadedOption = useMemo(() => {
|
||||
if (game?.uri) {
|
||||
if (game?.download) {
|
||||
const repack = repacks.find((repack) =>
|
||||
repack.uris.some((uri) => uri.includes(game.uri!))
|
||||
repack.uris.some((uri) => uri.includes(game.download!.uri))
|
||||
);
|
||||
|
||||
if (!repack) return null;
|
||||
@@ -200,7 +202,7 @@ export function GameDetailsContextProvider({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [game?.uri, repacks]);
|
||||
}, [game?.download, repacks]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onUpdateAchievements(
|
||||
@@ -250,7 +252,7 @@ export function GameDetailsContextProvider({
|
||||
value={{
|
||||
game,
|
||||
shopDetails,
|
||||
shop: shop as GameShop,
|
||||
shop,
|
||||
repacks,
|
||||
gameTitle,
|
||||
isGameRunning,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type {
|
||||
Game,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
LibraryGame,
|
||||
ShopDetails,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
game: Game | null;
|
||||
game: LibraryGame | null;
|
||||
shopDetails: ShopDetails | null;
|
||||
repacks: GameRepack[];
|
||||
shop: GameShop;
|
||||
|
||||
68
src/renderer/src/declaration.d.ts
vendored
68
src/renderer/src/declaration.d.ts
vendored
@@ -1,8 +1,6 @@
|
||||
import type { AuthPage, CatalogueCategory } from "@shared";
|
||||
import type {
|
||||
AppUpdaterEvent,
|
||||
Game,
|
||||
LibraryGame,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
@@ -23,12 +21,14 @@ import type {
|
||||
UserStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
GameAchievement,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
LibraryGame,
|
||||
GameRunning,
|
||||
TorBoxUser,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -41,12 +41,14 @@ declare global {
|
||||
|
||||
interface Electron {
|
||||
/* Torrenting */
|
||||
startGameDownload: (payload: StartGameDownloadPayload) => Promise<void>;
|
||||
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameSeed: (gameId: number) => Promise<void>;
|
||||
resumeGameSeed: (gameId: number) => Promise<void>;
|
||||
startGameDownload: (
|
||||
payload: StartGameDownloadPayload
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
onDownloadProgress: (
|
||||
cb: (value: DownloadProgress) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
@@ -77,52 +79,62 @@ declare global {
|
||||
onUpdateAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: (achievements: GameAchievement[]) => void
|
||||
cb: (achievements: UserAchievement[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
getPublishers: () => Promise<string[]>;
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
createGameShortcut: (id: number) => Promise<boolean>;
|
||||
createGameShortcut: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
updateExecutablePath: (
|
||||
id: number,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
executablePath: string | null
|
||||
) => Promise<void>;
|
||||
updateLaunchOptions: (
|
||||
id: number,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
launchOptions: string | null
|
||||
) => Promise<void>;
|
||||
selectGameWinePrefix: (
|
||||
id: number,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
winePrefixPath: string | null
|
||||
) => Promise<void>;
|
||||
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
||||
getLibrary: () => Promise<LibraryGame[]>;
|
||||
openGameInstaller: (gameId: number) => Promise<boolean>;
|
||||
openGameInstallerPath: (gameId: number) => Promise<boolean>;
|
||||
openGameExecutablePath: (gameId: number) => Promise<void>;
|
||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
openGameInstallerPath: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<boolean>;
|
||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
openGame: (
|
||||
gameId: number,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
executablePath: string,
|
||||
launchOptions: string | null
|
||||
launchOptions?: string | null
|
||||
) => Promise<void>;
|
||||
closeGame: (gameId: number) => Promise<boolean>;
|
||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||
removeGame: (gameId: number) => Promise<void>;
|
||||
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
||||
getGameByObjectId: (objectId: string) => Promise<Game | null>;
|
||||
closeGame: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
removeGame: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
|
||||
getGameByObjectId: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<LibraryGame | null>;
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
resetGameAchievements: (gameId: number) => Promise<void>;
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
@@ -133,6 +145,8 @@ declare global {
|
||||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { DownloadProgress } from "@types";
|
||||
|
||||
export interface DownloadState {
|
||||
lastPacket: DownloadProgress | null;
|
||||
gameId: number | null;
|
||||
gamesWithDeletionInProgress: number[];
|
||||
gameId: string | null;
|
||||
gamesWithDeletionInProgress: string[];
|
||||
}
|
||||
|
||||
const initialState: DownloadState = {
|
||||
@@ -20,13 +20,13 @@ export const downloadSlice = createSlice({
|
||||
reducers: {
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId) state.gameId = action.payload.game.id;
|
||||
if (!state.gameId) state.gameId = action.payload.gameId;
|
||||
},
|
||||
clearDownload: (state) => {
|
||||
state.lastPacket = null;
|
||||
state.gameId = null;
|
||||
},
|
||||
setGameDeleting: (state, action: PayloadAction<number>) => {
|
||||
setGameDeleting: (state, action: PayloadAction<string>) => {
|
||||
if (
|
||||
!state.gamesWithDeletionInProgress.includes(action.payload) &&
|
||||
action.payload
|
||||
@@ -34,7 +34,7 @@ export const downloadSlice = createSlice({
|
||||
state.gamesWithDeletionInProgress.push(action.payload);
|
||||
}
|
||||
},
|
||||
removeGameFromDeleting: (state, action: PayloadAction<number>) => {
|
||||
removeGameFromDeleting: (state, action: PayloadAction<string>) => {
|
||||
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
|
||||
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const initialState: GameRunningState = {
|
||||
};
|
||||
|
||||
export const gameRunningSlice = createSlice({
|
||||
name: "running-game",
|
||||
name: "game-running",
|
||||
initialState,
|
||||
reducers: {
|
||||
setGameRunning: (state, action: PayloadAction<GameRunning | null>) => {
|
||||
@@ -4,7 +4,7 @@ export * from "./download-slice";
|
||||
export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./game-running.slice";
|
||||
export * from "./subscription-slice";
|
||||
export * from "./repacks-slice";
|
||||
export * from "./catalogue-search";
|
||||
|
||||
@@ -3,14 +3,18 @@ import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ToastProps } from "@renderer/components/toast/toast";
|
||||
|
||||
export interface ToastState {
|
||||
message: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
type: ToastProps["type"];
|
||||
duration?: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
title: "",
|
||||
message: "",
|
||||
type: "success",
|
||||
duration: 5000,
|
||||
visible: false,
|
||||
};
|
||||
|
||||
@@ -19,8 +23,10 @@ export const toastSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => {
|
||||
state.title = action.payload.title;
|
||||
state.message = action.payload.message;
|
||||
state.type = action.payload.type;
|
||||
state.duration = action.payload.duration ?? 5000;
|
||||
state.visible = true;
|
||||
},
|
||||
closeToast: (state) => {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
} from "@renderer/features";
|
||||
import type { DownloadProgress, StartGameDownloadPayload } from "@types";
|
||||
import type {
|
||||
DownloadProgress,
|
||||
GameShop,
|
||||
StartGameDownloadPayload,
|
||||
} from "@types";
|
||||
import { useDate } from "./use-date";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
@@ -25,54 +29,55 @@ export function useDownload() {
|
||||
const startDownload = async (payload: StartGameDownloadPayload) => {
|
||||
dispatch(clearDownload());
|
||||
|
||||
const game = await window.electron.startGameDownload(payload);
|
||||
const response = await window.electron.startGameDownload(payload);
|
||||
|
||||
await updateLibrary();
|
||||
return game;
|
||||
if (response.ok) updateLibrary();
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const pauseDownload = async (gameId: number) => {
|
||||
await window.electron.pauseGameDownload(gameId);
|
||||
const pauseDownload = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.pauseGameDownload(shop, objectId);
|
||||
await updateLibrary();
|
||||
dispatch(clearDownload());
|
||||
};
|
||||
|
||||
const resumeDownload = async (gameId: number) => {
|
||||
await window.electron.resumeGameDownload(gameId);
|
||||
const resumeDownload = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.resumeGameDownload(shop, objectId);
|
||||
return updateLibrary();
|
||||
};
|
||||
|
||||
const removeGameInstaller = async (gameId: number) => {
|
||||
dispatch(setGameDeleting(gameId));
|
||||
const removeGameInstaller = async (shop: GameShop, objectId: string) => {
|
||||
dispatch(setGameDeleting(objectId));
|
||||
|
||||
try {
|
||||
await window.electron.deleteGameFolder(gameId);
|
||||
await window.electron.deleteGameFolder(shop, objectId);
|
||||
updateLibrary();
|
||||
} finally {
|
||||
dispatch(removeGameFromDeleting(gameId));
|
||||
dispatch(removeGameFromDeleting(objectId));
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDownload = async (gameId: number) => {
|
||||
await window.electron.cancelGameDownload(gameId);
|
||||
const cancelDownload = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.cancelGameDownload(shop, objectId);
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
removeGameInstaller(gameId);
|
||||
removeGameInstaller(shop, objectId);
|
||||
};
|
||||
|
||||
const removeGameFromLibrary = (gameId: number) =>
|
||||
window.electron.removeGameFromLibrary(gameId).then(() => {
|
||||
const removeGameFromLibrary = (shop: GameShop, objectId: string) =>
|
||||
window.electron.removeGameFromLibrary(shop, objectId).then(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const pauseSeeding = async (gameId: number) => {
|
||||
await window.electron.pauseGameSeed(gameId);
|
||||
const pauseSeeding = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.pauseGameSeed(shop, objectId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
const resumeSeeding = async (gameId: number) => {
|
||||
await window.electron.resumeGameSeed(gameId);
|
||||
const resumeSeeding = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.resumeGameSeed(shop, objectId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
@@ -90,8 +95,8 @@ export function useDownload() {
|
||||
}
|
||||
};
|
||||
|
||||
const isGameDeleting = (gameId: number) => {
|
||||
return gamesWithDeletionInProgress.includes(gameId);
|
||||
const isGameDeleting = (objectId: string) => {
|
||||
return gamesWithDeletionInProgress.includes(objectId);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,11 +6,13 @@ export function useToast() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const showSuccessToast = useCallback(
|
||||
(message: string) => {
|
||||
(title: string, message?: string, duration?: number) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
title,
|
||||
message,
|
||||
type: "success",
|
||||
duration,
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -18,11 +20,13 @@ export function useToast() {
|
||||
);
|
||||
|
||||
const showErrorToast = useCallback(
|
||||
(message: string) => {
|
||||
(title: string, message?: string, duration?: number) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
title,
|
||||
message,
|
||||
type: "error",
|
||||
duration,
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -30,11 +34,13 @@ export function useToast() {
|
||||
);
|
||||
|
||||
const showWarningToast = useCallback(
|
||||
(message: string) => {
|
||||
(title: string, message?: string, duration?: number) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
title,
|
||||
message,
|
||||
type: "warning",
|
||||
duration,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -78,9 +78,15 @@ export function useUserDetails() {
|
||||
...response,
|
||||
username: userDetails?.username || "",
|
||||
subscription: userDetails?.subscription || null,
|
||||
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
||||
});
|
||||
},
|
||||
[updateUserDetails, userDetails?.username, userDetails?.subscription]
|
||||
[
|
||||
updateUserDetails,
|
||||
userDetails?.username,
|
||||
userDetails?.subscription,
|
||||
userDetails?.featurebaseJwt,
|
||||
]
|
||||
);
|
||||
|
||||
const syncFriendRequests = useCallback(async () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ Sentry.init({
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
release: await window.electron.getVersion(),
|
||||
});
|
||||
|
||||
console.log = logger.log;
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import type { UserAchievement } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievements.css";
|
||||
import "./achievements.scss";
|
||||
import { EyeClosedIcon } from "@primer/octicons-react";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
interface AchievementListProps {
|
||||
achievements: UserAchievement[];
|
||||
}
|
||||
|
||||
export function AchievementList({ achievements }: AchievementListProps) {
|
||||
export function AchievementList({
|
||||
achievements,
|
||||
}: Readonly<AchievementListProps>) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
<ul className="achievements__list">
|
||||
{achievements.map((achievement) => (
|
||||
<li
|
||||
key={achievement.name}
|
||||
className={styles.listItem}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<li key={achievement.name} className="achievements__item">
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div className="achievements__item-content">
|
||||
<h4 className="achievements__item-title">
|
||||
{achievement.hidden && (
|
||||
<span
|
||||
style={{ display: "flex" }}
|
||||
className="achievements__item-hidden-icon"
|
||||
title={t("hidden_achievement_tooltip")}
|
||||
>
|
||||
<EyeClosedIcon size={12} />
|
||||
@@ -47,41 +42,36 @@ export function AchievementList({ achievements }: AchievementListProps) {
|
||||
</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
|
||||
<div className="achievements__item-meta">
|
||||
{achievement.points != undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px" }}
|
||||
className="achievements__item-points"
|
||||
title={t("achievement_earn_points", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p>
|
||||
<HydraIcon className="achievements__item-points-icon" />
|
||||
<p className="achievements__item-points-value">
|
||||
{achievement.points}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
cursor: "pointer",
|
||||
color: vars.color.warning,
|
||||
}}
|
||||
title={t("achievement_earn_points", {
|
||||
points: "???",
|
||||
})}
|
||||
className="achievements__item-points achievements__item-points--locked"
|
||||
title={t("achievement_earn_points", { points: "???" })}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>???</p>
|
||||
<HydraIcon className="achievements__item-points-icon" />
|
||||
<p className="achievements__item-points-value">???</p>
|
||||
</button>
|
||||
)}
|
||||
{achievement.unlockTime != null && (
|
||||
<div
|
||||
className="achievements__item-unlock-time"
|
||||
title={t("unlocked_at", {
|
||||
date: formatDateTime(achievement.unlockTime),
|
||||
})}
|
||||
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
|
||||
>
|
||||
<small>{formatDateTime(achievement.unlockTime)}</small>
|
||||
</div>
|
||||
|
||||
262
src/renderer/src/pages/achievements/achievements.scss
Normal file
262
src/renderer/src/pages/achievements/achievements.scss
Normal file
@@ -0,0 +1,262 @@
|
||||
@use "../../scss/globals.scss";
|
||||
@use "sass:math";
|
||||
|
||||
$hero-height: 150px;
|
||||
$logo-height: 100px;
|
||||
$logo-max-width: 200px;
|
||||
|
||||
.achievements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
&__hero {
|
||||
width: 100%;
|
||||
height: $hero-height;
|
||||
min-height: $hero-height;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&-content {
|
||||
padding: globals.$spacing-unit * 2;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-logo-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-image-skeleton {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-logo {
|
||||
width: $logo-max-width;
|
||||
height: $logo-height;
|
||||
object-fit: contain;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__table-header {
|
||||
width: 100%;
|
||||
background-color: var(--color-dark-background);
|
||||
transition: all ease 0.2s;
|
||||
border-bottom: solid 1px var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
&--stuck {
|
||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit * 2;
|
||||
padding: globals.$spacing-unit * 2;
|
||||
width: 100%;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
transition: all ease 0.1s;
|
||||
color: var(--color-muted);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
padding: globals.$spacing-unit globals.$spacing-unit;
|
||||
gap: globals.$spacing-unit * 2;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
|
||||
&--locked {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-hidden-icon {
|
||||
display: flex;
|
||||
color: var(--color-warning);
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&-eye-closed {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: globals.$warning-color;
|
||||
scale: 4;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 4px;
|
||||
font-weight: 600;
|
||||
|
||||
&--locked {
|
||||
cursor: pointer;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
&-unlock-time {
|
||||
white-space: nowrap;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-compared {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr 1fr;
|
||||
|
||||
&--no-owner {
|
||||
grid-template-columns: 3fr 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
&-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&-status {
|
||||
display: flex;
|
||||
padding: globals.$spacing-unit;
|
||||
justify-content: center;
|
||||
|
||||
&--unlocked {
|
||||
white-space: nowrap;
|
||||
flex-direction: row;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--color-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__profile-avatar {
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
position: relative;
|
||||
object-fit: cover;
|
||||
|
||||
&--small {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__subscription-button {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: math.div(globals.$spacing-unit, 2);
|
||||
color: var(--color-body);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export default function Achievements() {
|
||||
.getComparedUnlockedAchievements(objectId, shop as GameShop, userId)
|
||||
.then(setComparedAchievements);
|
||||
}
|
||||
}, [objectId, shop, userId]);
|
||||
}, [objectId, shop, userDetails?.id, userId]);
|
||||
|
||||
const otherUserId = userDetails?.id === userId ? null : userId;
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const downloadTitleWrapper = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadTitle = style({
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
textAlign: "left",
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
width: "100%",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadCover = style({
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
height: "auto",
|
||||
borderRight: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const downloadCoverContent = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "flex-end",
|
||||
});
|
||||
|
||||
export const downloadCoverBackdrop = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const downloadCoverImage = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
zIndex: "-1",
|
||||
});
|
||||
|
||||
export const download = style({
|
||||
width: "100%",
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
borderRadius: "8px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0px 0px 5px 0px #000000",
|
||||
transition: "all ease 0.2s",
|
||||
height: "140px",
|
||||
minHeight: "140px",
|
||||
maxHeight: "140px",
|
||||
});
|
||||
|
||||
export const downloadDetails = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "1",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
fontSize: "14px",
|
||||
});
|
||||
|
||||
export const downloadRightContent = style({
|
||||
display: "flex",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)",
|
||||
});
|
||||
|
||||
export const downloadActions = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadGroup = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
140
src/renderer/src/pages/downloads/download-group.scss
Normal file
140
src/renderer/src/pages/downloads/download-group.scss
Normal file
@@ -0,0 +1,140 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.download-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&-divider {
|
||||
flex: 1;
|
||||
background-color: globals.$border-color;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: globals.$spacing-unit;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: globals.$body-color;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__downloads {
|
||||
width: 100%;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
background-color: globals.$background-color;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
border: solid 1px globals.$border-color;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 0px 5px 0px #000000;
|
||||
transition: all ease 0.2s;
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
max-height: 140px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
height: auto;
|
||||
border-right: solid 1px globals.$border-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: globals.$spacing-unit;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.8) 5%,
|
||||
transparent 100%
|
||||
);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&__right-content {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
flex: 1;
|
||||
gap: globals.$spacing-unit;
|
||||
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__menu-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import {
|
||||
@@ -12,9 +12,8 @@ import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useDownload } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./download-group.css";
|
||||
import "./download-group.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,11 +30,14 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
openDeleteGameModal: (gameId: number) => void;
|
||||
openGameInstaller: (gameId: number) => void;
|
||||
openDeleteGameModal: (shop: GameShop, objectId: string) => void;
|
||||
openGameInstaller: (shop: GameShop, objectId: string) => void;
|
||||
seedingStatus: SeedingStatus[];
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ export function DownloadGroup({
|
||||
openDeleteGameModal,
|
||||
openGameInstaller,
|
||||
seedingStatus,
|
||||
}: DownloadGroupProps) {
|
||||
}: Readonly<DownloadGroupProps>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
@@ -66,18 +68,19 @@ export function DownloadGroup({
|
||||
} = useDownload();
|
||||
|
||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const download = game.download!;
|
||||
const isGameDownloading = lastPacket?.gameId === game.id;
|
||||
|
||||
if (game.fileSize) return formatBytes(game.fileSize);
|
||||
if (download.fileSize) return formatBytes(download.fileSize);
|
||||
|
||||
if (lastPacket?.game.fileSize && isGameDownloading)
|
||||
return formatBytes(lastPacket?.game.fileSize);
|
||||
if (lastPacket?.download.fileSize && isGameDownloading)
|
||||
return formatBytes(lastPacket.download.fileSize);
|
||||
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
const seedingMap = useMemo(() => {
|
||||
const map = new Map<number, SeedingStatus>();
|
||||
const map = new Map<string, SeedingStatus>();
|
||||
|
||||
seedingStatus.forEach((seed) => {
|
||||
map.set(seed.gameId, seed);
|
||||
@@ -87,7 +90,9 @@ export function DownloadGroup({
|
||||
}, [seedingStatus]);
|
||||
|
||||
const getGameInfo = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const download = game.download!;
|
||||
|
||||
const isGameDownloading = lastPacket?.gameId === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const seedingStatus = seedingMap.get(game.id);
|
||||
|
||||
@@ -114,11 +119,11 @@ export function DownloadGroup({
|
||||
<p>{progress}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
|
||||
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
|
||||
{game.downloader === Downloader.Torrent && (
|
||||
{download.downloader === Downloader.Torrent && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
@@ -127,11 +132,11 @@ export function DownloadGroup({
|
||||
);
|
||||
}
|
||||
|
||||
if (game.progress === 1) {
|
||||
if (download.progress === 1) {
|
||||
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
|
||||
|
||||
return game.status === "seeding" &&
|
||||
game.downloader === Downloader.Torrent ? (
|
||||
return download.status === "seeding" &&
|
||||
download.downloader === Downloader.Torrent ? (
|
||||
<>
|
||||
<p>{t("seeding")}</p>
|
||||
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||
@@ -141,41 +146,44 @@ export function DownloadGroup({
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
if (download.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
<p>{t(game.downloadQueue && lastPacket ? "queued" : "paused")}</p>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t(download.queued ? "queued" : "paused")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "active") {
|
||||
if (download.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
|
||||
<p>
|
||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
{formatBytes(download.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t(game.status as string)}</p>;
|
||||
return <p>{t(download.status as string)}</p>;
|
||||
};
|
||||
|
||||
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const download = lastPacket?.download;
|
||||
const isGameDownloading = lastPacket?.gameId === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (game.progress === 1) {
|
||||
if (download?.progress === 1) {
|
||||
return [
|
||||
{
|
||||
label: t("install"),
|
||||
disabled: deleting,
|
||||
onClick: () => openGameInstaller(game.id),
|
||||
onClick: () => {
|
||||
openGameInstaller(game.shop, game.objectId);
|
||||
},
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
@@ -183,53 +191,73 @@ export function DownloadGroup({
|
||||
disabled: deleting,
|
||||
icon: <UnlinkIcon />,
|
||||
show:
|
||||
game.status === "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => pauseSeeding(game.id),
|
||||
download.status === "seeding" &&
|
||||
download.downloader === Downloader.Torrent,
|
||||
onClick: () => {
|
||||
pauseSeeding(game.shop, game.objectId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("resume_seeding"),
|
||||
disabled: deleting,
|
||||
icon: <LinkIcon />,
|
||||
show:
|
||||
game.status !== "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => resumeSeeding(game.id),
|
||||
download.status !== "seeding" &&
|
||||
download.downloader === Downloader.Torrent,
|
||||
onClick: () => {
|
||||
resumeSeeding(game.shop, game.objectId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("delete"),
|
||||
disabled: deleting,
|
||||
icon: <TrashIcon />,
|
||||
onClick: () => openDeleteGameModal(game.id),
|
||||
onClick: () => {
|
||||
openDeleteGameModal(game.shop, game.objectId);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
if (isGameDownloading || download?.status === "active") {
|
||||
return [
|
||||
{
|
||||
label: t("pause"),
|
||||
onClick: () => pauseDownload(game.id),
|
||||
onClick: () => {
|
||||
pauseDownload(game.shop, game.objectId);
|
||||
},
|
||||
icon: <ColumnsIcon />,
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isResumeDisabled =
|
||||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("resume"),
|
||||
disabled:
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken,
|
||||
onClick: () => resumeDownload(game.id),
|
||||
disabled: isResumeDisabled,
|
||||
onClick: () => {
|
||||
resumeDownload(game.shop, game.objectId);
|
||||
},
|
||||
icon: <PlayIcon />,
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
];
|
||||
@@ -238,59 +266,64 @@ export function DownloadGroup({
|
||||
if (!library.length) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.downloadGroup}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<div className="download-group">
|
||||
<div className="download-group__header">
|
||||
<h2>{title}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>{library.length}</h3>
|
||||
<div className="download-group__header-divider" />
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
</div>
|
||||
|
||||
<ul className={styles.downloads}>
|
||||
<ul className="download-group__downloads">
|
||||
{library.map((game) => {
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.download}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<div className={styles.downloadCover}>
|
||||
<div className={styles.downloadCoverBackdrop}>
|
||||
<li key={game.id} className="download-group__item">
|
||||
<div className="download-group__cover">
|
||||
<div className="download-group__cover-backdrop">
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectID)}
|
||||
className={styles.downloadCoverImage}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
className="download-group__cover-image"
|
||||
alt={game.title}
|
||||
/>
|
||||
|
||||
<div className={styles.downloadCoverContent}>
|
||||
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
|
||||
<div className="download-group__cover-content">
|
||||
{game.download?.downloader === Downloader.TorBox ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "#11141b",
|
||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
||||
borderRadius: "4px",
|
||||
gap: 4,
|
||||
border: `1px solid ${vars.color.border}`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={torBoxLogo}
|
||||
alt="TorBox"
|
||||
style={{ width: 13 }}
|
||||
/>
|
||||
<span style={{ fontSize: 10 }}>TorBox</span>
|
||||
</div>
|
||||
) : (
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[game.download!.downloader]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.downloadRightContent}>
|
||||
<div className={styles.downloadDetails}>
|
||||
<div className={styles.downloadTitleWrapper}>
|
||||
<div className="download-group__right-content">
|
||||
<div className="download-group__details">
|
||||
<div className="download-group__title-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
className="download-group__title"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectID,
|
||||
objectId: game.objectId,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -309,15 +342,7 @@ export function DownloadGroup({
|
||||
sideOffset={-75}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "12px",
|
||||
right: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
padding: "8px",
|
||||
minHeight: "unset",
|
||||
}}
|
||||
className="download-group__menu-button"
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
|
||||
@@ -7,8 +7,8 @@ import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy } from "lodash-es";
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy, sortBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
export default function Downloads() {
|
||||
@@ -16,7 +16,7 @@ export default function Downloads() {
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const gameToBeDeleted = useRef<number | null>(null);
|
||||
const gameToBeDeleted = useRef<[GameShop, string] | null>(null);
|
||||
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
@@ -25,8 +25,10 @@ export default function Downloads() {
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
if (gameToBeDeleted.current) {
|
||||
await pauseSeeding(gameToBeDeleted.current);
|
||||
await removeGameInstaller(gameToBeDeleted.current);
|
||||
const [shop, objectId] = gameToBeDeleted.current;
|
||||
|
||||
await pauseSeeding(shop, objectId);
|
||||
await removeGameInstaller(shop, objectId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,14 +40,14 @@ export default function Downloads() {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
}, []);
|
||||
|
||||
const handleOpenGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
|
||||
window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const handleOpenDeleteGameModal = (gameId: number) => {
|
||||
gameToBeDeleted.current = gameId;
|
||||
const handleOpenDeleteGameModal = (shop: GameShop, objectId: string) => {
|
||||
gameToBeDeleted.current = [shop, objectId];
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
@@ -56,29 +58,31 @@ export default function Downloads() {
|
||||
complete: [],
|
||||
};
|
||||
|
||||
const result = library.reduce((prev, next) => {
|
||||
/* Game has been manually added to the library or has been canceled */
|
||||
if (!next.status || next.status === "removed") return prev;
|
||||
const result = sortBy(library, (game) => game.download?.timestamp).reduce(
|
||||
(prev, next) => {
|
||||
/* Game has been manually added to the library or has been canceled */
|
||||
if (!next.download?.status || next.download?.status === "removed")
|
||||
return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.game.id === next.id)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
if (next.downloadQueue || next.status === "paused")
|
||||
return { ...prev, queued: [...prev.queued, next] };
|
||||
/* Is either queued or paused */
|
||||
if (next.download.queued || next.download?.status === "paused")
|
||||
return { ...prev, queued: [...prev.queued, next] };
|
||||
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
}, initialValue);
|
||||
|
||||
const queued = orderBy(
|
||||
result.queued,
|
||||
(game) => game.downloadQueue?.id ?? -1,
|
||||
["desc"]
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
},
|
||||
initialValue
|
||||
);
|
||||
|
||||
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
|
||||
"desc",
|
||||
]);
|
||||
|
||||
const complete = orderBy(result.complete, (game) =>
|
||||
game.progress === 1 ? 0 : 1
|
||||
game.download?.progress === 1 ? 0 : 1
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -86,7 +90,7 @@ export default function Downloads() {
|
||||
queued,
|
||||
complete,
|
||||
};
|
||||
}, [library, lastPacket?.game.id]);
|
||||
}, [library, lastPacket?.gameId]);
|
||||
|
||||
const downloadGroups = [
|
||||
{
|
||||
|
||||
@@ -102,19 +102,23 @@ export default function GameDetails() {
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => {
|
||||
await startDownload({
|
||||
const response = await startDownload({
|
||||
repackId: repack.id,
|
||||
objectId: objectId!,
|
||||
title: gameTitle,
|
||||
downloader,
|
||||
shop: shop as GameShop,
|
||||
shop,
|
||||
downloadPath,
|
||||
uri: selectRepackUri(repack, downloader),
|
||||
});
|
||||
|
||||
await updateGame();
|
||||
setShowRepacksModal(false);
|
||||
setShowGameOptionsModal(false);
|
||||
if (response.ok) {
|
||||
await updateGame();
|
||||
setShowRepacksModal(false);
|
||||
setShowGameOptionsModal(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const handleNSFWContentRefuse = () => {
|
||||
@@ -123,10 +127,7 @@ export default function GameDetails() {
|
||||
};
|
||||
|
||||
return (
|
||||
<CloudSyncContextProvider
|
||||
objectId={objectId!}
|
||||
shop={shop! as GameShop}
|
||||
>
|
||||
<CloudSyncContextProvider objectId={objectId!} shop={shop}>
|
||||
<CloudSyncContextConsumer>
|
||||
{({
|
||||
showCloudSyncModal,
|
||||
|
||||
@@ -22,6 +22,7 @@ export function HeroPanelActions() {
|
||||
game,
|
||||
repacks,
|
||||
isGameRunning,
|
||||
shop,
|
||||
objectId,
|
||||
gameTitle,
|
||||
setShowGameOptionsModal,
|
||||
@@ -33,7 +34,7 @@ export function HeroPanelActions() {
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
@@ -43,7 +44,7 @@ export function HeroPanelActions() {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
await window.electron.addGameToLibrary(objectId!, gameTitle, "steam");
|
||||
await window.electron.addGameToLibrary(shop, objectId!, gameTitle);
|
||||
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
@@ -56,7 +57,8 @@ export function HeroPanelActions() {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
game.shop,
|
||||
game.objectId,
|
||||
game.executablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
@@ -66,7 +68,8 @@ export function HeroPanelActions() {
|
||||
const gameExecutablePath = await selectGameExecutable();
|
||||
if (gameExecutablePath)
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameExecutablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
@@ -74,7 +77,7 @@ export function HeroPanelActions() {
|
||||
};
|
||||
|
||||
const closeGame = () => {
|
||||
if (game) window.electron.closeGame(game.id);
|
||||
if (game) window.electron.closeGame(game.shop, game.objectId);
|
||||
};
|
||||
|
||||
const deleting = game ? isGameDeleting(game?.id) : false;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.hero-panel-playtime {
|
||||
&__download-details {
|
||||
gap: globals.$spacing-unit;
|
||||
display: flex;
|
||||
color: globals.$body-color;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__downloads-link {
|
||||
color: globals.$body-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useDate, useDownload, useFormat } from "@renderer/hooks";
|
||||
import { Link } from "@renderer/components";
|
||||
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import "./hero-panel-playtime.scss";
|
||||
|
||||
export function HeroPanelPlaytime() {
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const { progress, lastPacket } = useDownload();
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,21 +43,24 @@ export function HeroPanelPlaytime() {
|
||||
if (!game) return null;
|
||||
|
||||
const hasDownload =
|
||||
["active", "paused"].includes(game.status as string) && game.progress !== 1;
|
||||
["active", "paused"].includes(game.download?.status as string) &&
|
||||
game.download?.progress !== 1;
|
||||
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
|
||||
const downloadInProgressInfo = (
|
||||
<div className={styles.downloadDetailsRow}>
|
||||
<Link to="/downloads" className={styles.downloadsLink}>
|
||||
{game.status === "active"
|
||||
<div className="hero-panel-playtime__download-details">
|
||||
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
|
||||
{game.download?.status === "active"
|
||||
? t("download_in_progress")
|
||||
: t("download_paused")}
|
||||
</Link>
|
||||
|
||||
<small>
|
||||
{isGameDownloading ? progress : formatDownloadProgress(game.progress)}
|
||||
{isGameDownloading
|
||||
? progress
|
||||
: formatDownloadProgress(game.download?.progress)}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
@@ -81,7 +78,6 @@ export function HeroPanelPlaytime() {
|
||||
return (
|
||||
<>
|
||||
<p>{t("playing_now")}</p>
|
||||
|
||||
{hasDownload && downloadInProgressInfo}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const panel = recipe({
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "72px",
|
||||
minHeight: "72px",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
transition: "all ease 0.2s",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
position: "sticky",
|
||||
overflow: "hidden",
|
||||
top: "0",
|
||||
zIndex: "2",
|
||||
},
|
||||
variants: {
|
||||
stuck: {
|
||||
true: {
|
||||
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.8)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const actions = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadDetailsRow = style({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
color: vars.color.body,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const downloadsLink = style({
|
||||
color: vars.color.body,
|
||||
textDecoration: "underline",
|
||||
});
|
||||
|
||||
export const progressBar = recipe({
|
||||
base: {
|
||||
position: "absolute",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "3px",
|
||||
transition: "all ease 0.2s",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
66
src/renderer/src/pages/game-details/hero/hero-panel.scss
Normal file
66
src/renderer/src/pages/game-details/hero/hero-panel.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.hero-panel {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
min-height: 72px;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
|
||||
background-color: globals.$dark-background-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all ease 0.2s;
|
||||
border-bottom: solid 1px globals.$border-color;
|
||||
position: sticky;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
&--stuck {
|
||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__download-details {
|
||||
gap: globals.$spacing-unit;
|
||||
display: flex;
|
||||
color: globals.$body-color;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__downloads-link {
|
||||
color: globals.$body-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: globals.$disabled-opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { useDate, useDownload } from "@renderer/hooks";
|
||||
|
||||
import { HeroPanelActions } from "./hero-panel-actions";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import "./hero-panel.scss";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
isHeaderStuck: boolean;
|
||||
@@ -23,7 +23,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
|
||||
|
||||
const getInfo = () => {
|
||||
if (!game) {
|
||||
@@ -50,32 +50,32 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
|
||||
};
|
||||
|
||||
const showProgressBar =
|
||||
(game?.status === "active" && game?.progress < 1) ||
|
||||
game?.status === "paused";
|
||||
(game?.download?.status === "active" && game?.download?.progress < 1) ||
|
||||
game?.download?.status === "paused";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ backgroundColor: gameColor }}
|
||||
className={styles.panel({ stuck: isHeaderStuck })}
|
||||
>
|
||||
<div className={styles.content}>{getInfo()}</div>
|
||||
<div className={styles.actions}>
|
||||
<HeroPanelActions />
|
||||
</div>
|
||||
|
||||
{showProgressBar && (
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
isGameDownloading ? lastPacket?.game.progress : game?.progress
|
||||
}
|
||||
className={styles.progressBar({
|
||||
disabled: game?.status === "paused",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{ backgroundColor: gameColor }}
|
||||
className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
|
||||
>
|
||||
<div className="hero-panel__content">{getInfo()}</div>
|
||||
<div className="hero-panel__actions">
|
||||
<HeroPanelActions />
|
||||
</div>
|
||||
</>
|
||||
|
||||
{showProgressBar && (
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
isGameDownloading ? lastPacket?.progress : game?.download?.progress
|
||||
}
|
||||
className={`hero-panel__progress-bar ${
|
||||
game?.download?.status === "paused"
|
||||
? "hero-panel__progress-bar--disabled"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface DownloadSettingsModalProps {
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DownloadSettingsModal({
|
||||
onClose,
|
||||
startDownload,
|
||||
repack,
|
||||
}: DownloadSettingsModalProps) {
|
||||
}: Readonly<DownloadSettingsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { showErrorToast } = useToast();
|
||||
@@ -88,19 +88,17 @@ export function DownloadSettingsModal({
|
||||
const filteredDownloaders = downloaders.filter((downloader) => {
|
||||
if (downloader === Downloader.RealDebrid)
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
return true;
|
||||
});
|
||||
|
||||
/* Gives preference to Real Debrid */
|
||||
const selectedDownloader = filteredDownloaders.includes(
|
||||
Downloader.RealDebrid
|
||||
)
|
||||
? Downloader.RealDebrid
|
||||
/* Gives preference to TorBox */
|
||||
const selectedDownloader = filteredDownloaders.includes(Downloader.TorBox)
|
||||
? Downloader.TorBox
|
||||
: filteredDownloaders[0];
|
||||
|
||||
setSelectedDownloader(
|
||||
selectedDownloader === undefined ? null : selectedDownloader
|
||||
);
|
||||
setSelectedDownloader(selectedDownloader ?? null);
|
||||
}, [
|
||||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
@@ -119,20 +117,30 @@ export function DownloadSettingsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
const handleStartClick = async () => {
|
||||
if (repack) {
|
||||
setDownloadStarting(true);
|
||||
|
||||
startDownload(repack, selectedDownloader!, selectedPath)
|
||||
.then(() => {
|
||||
try {
|
||||
const response = await startDownload(
|
||||
repack,
|
||||
selectedDownloader!,
|
||||
selectedPath
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("download_error"));
|
||||
})
|
||||
.finally(() => {
|
||||
setDownloadStarting(false);
|
||||
});
|
||||
return;
|
||||
} else if (response.error) {
|
||||
showErrorToast(t("download_error"), t(response.error), 4_000);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
showErrorToast(t("download_error"), error.message, 4_000);
|
||||
}
|
||||
} finally {
|
||||
setDownloadStarting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { Game } from "@types";
|
||||
import type { LibraryGame } from "@types";
|
||||
import * as styles from "./game-options-modal.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
@@ -13,7 +13,7 @@ import { debounce } from "lodash-es";
|
||||
|
||||
export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
game: LibraryGame;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
}: GameOptionsModalProps) {
|
||||
}: Readonly<GameOptionsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
@@ -59,21 +59,25 @@ export function GameOptionsModal({
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
|
||||
const debounceUpdateLaunchOptions = useRef(
|
||||
debounce(async (value: string) => {
|
||||
await window.electron.updateLaunchOptions(game.id, value);
|
||||
await window.electron.updateLaunchOptions(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
value
|
||||
);
|
||||
updateGame();
|
||||
}, 1000)
|
||||
).current;
|
||||
|
||||
const handleRemoveGameFromLibrary = async () => {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.id);
|
||||
await cancelDownload(game.shop, game.objectId);
|
||||
}
|
||||
|
||||
await removeGameFromLibrary(game.id);
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
updateGame();
|
||||
onClose();
|
||||
};
|
||||
@@ -92,35 +96,39 @@ export function GameOptionsModal({
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron.updateExecutablePath(game.id, path).then(updateGame);
|
||||
window.electron
|
||||
.updateExecutablePath(game.shop, game.objectId, path)
|
||||
.then(updateGame);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = async () => {
|
||||
window.electron.createGameShortcut(game.id).then((success) => {
|
||||
if (success) {
|
||||
showSuccessToast(t("create_shortcut_success"));
|
||||
} else {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
}
|
||||
});
|
||||
window.electron
|
||||
.createGameShortcut(game.shop, game.objectId)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
showSuccessToast(t("create_shortcut_success"));
|
||||
} else {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenDownloadFolder = async () => {
|
||||
await window.electron.openGameInstallerPath(game.id);
|
||||
await window.electron.openGameInstallerPath(game.shop, game.objectId);
|
||||
};
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
await removeGameInstaller(game.id);
|
||||
await removeGameInstaller(game.shop, game.objectId);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleOpenGameExecutablePath = async () => {
|
||||
await window.electron.openGameExecutablePath(game.id);
|
||||
await window.electron.openGameExecutablePath(game.shop, game.objectId);
|
||||
};
|
||||
|
||||
const handleClearExecutablePath = async () => {
|
||||
await window.electron.updateExecutablePath(game.id, null);
|
||||
await window.electron.updateExecutablePath(game.shop, game.objectId, null);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
@@ -130,13 +138,17 @@ export function GameOptionsModal({
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
await window.electron.selectGameWinePrefix(game.id, filePaths[0]);
|
||||
await window.electron.selectGameWinePrefix(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
filePaths[0]
|
||||
);
|
||||
await updateGame();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearWinePrefixPath = async () => {
|
||||
await window.electron.selectGameWinePrefix(game.id, null);
|
||||
await window.electron.selectGameWinePrefix(game.shop, game.objectId, null);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
@@ -150,7 +162,9 @@ export function GameOptionsModal({
|
||||
const handleClearLaunchOptions = async () => {
|
||||
setLaunchOptions("");
|
||||
|
||||
window.electron.updateLaunchOptions(game.id, null).then(updateGame);
|
||||
window.electron
|
||||
.updateLaunchOptions(game.shop, game.objectId, null)
|
||||
.then(updateGame);
|
||||
};
|
||||
|
||||
const shouldShowWinePrefixConfiguration =
|
||||
@@ -159,7 +173,7 @@ export function GameOptionsModal({
|
||||
const handleResetAchievements = async () => {
|
||||
setIsDeletingAchievements(true);
|
||||
try {
|
||||
await window.electron.resetGameAchievements(game.id);
|
||||
await window.electron.resetGameAchievements(game.shop, game.objectId);
|
||||
await updateGame();
|
||||
showSuccessToast(t("reset_achievements_success"));
|
||||
} catch (error) {
|
||||
@@ -169,8 +183,6 @@ export function GameOptionsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowLaunchOptionsConfiguration = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteGameModal
|
||||
@@ -285,27 +297,28 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowLaunchOptionsConfiguration && (
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("launch_options")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
{t("launch_options_description")}
|
||||
</h4>
|
||||
<TextField
|
||||
value={launchOptions}
|
||||
theme="dark"
|
||||
placeholder={t("launch_options_placeholder")}
|
||||
onChange={handleChangeLaunchOptions}
|
||||
rightContent={
|
||||
game.launchOptions && (
|
||||
<Button onClick={handleClearLaunchOptions} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
value={launchOptions}
|
||||
theme="dark"
|
||||
placeholder={t("launch_options_placeholder")}
|
||||
onChange={handleChangeLaunchOptions}
|
||||
rightContent={
|
||||
game.launchOptions && (
|
||||
<Button onClick={handleClearLaunchOptions} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("downloads_secion_title")}</h2>
|
||||
@@ -322,7 +335,7 @@ export function GameOptionsModal({
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{game.downloadPath && (
|
||||
{game.download?.downloadPath && (
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
theme="outline"
|
||||
@@ -367,7 +380,9 @@ export function GameOptionsModal({
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={isGameDownloading || deleting || !game.downloadPath}
|
||||
disabled={
|
||||
isGameDownloading || deleting || !game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface RepacksModalProps {
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RepacksModal({
|
||||
visible,
|
||||
startDownload,
|
||||
onClose,
|
||||
}: RepacksModalProps) {
|
||||
}: Readonly<RepacksModalProps>) {
|
||||
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
|
||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
@@ -67,8 +67,8 @@ export function RepacksModal({
|
||||
};
|
||||
|
||||
const checkIfLastDownloadedOption = (repack: GameRepack) => {
|
||||
if (!game) return false;
|
||||
return repack.uris.some((uri) => uri.includes(game.uri!));
|
||||
if (!game?.download) return false;
|
||||
return repack.uris.some((uri) => uri.includes(game.download!.uri));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -111,7 +111,7 @@ export function RepacksModal({
|
||||
|
||||
<p style={{ fontSize: "12px" }}>
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
</Button>
|
||||
);
|
||||
|
||||
174
src/renderer/src/pages/game-details/sidebar/sidebar.scss
Normal file
174
src/renderer/src/pages/game-details/sidebar/sidebar.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.content-sidebar {
|
||||
border-left: solid 1px globals.$border-color;
|
||||
background-color: globals.$dark-background-color;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.requirement {
|
||||
&__button-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__button {
|
||||
border: solid 1px globals.$border-color;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__details {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
line-height: 22px;
|
||||
font-size: globals.$body-font-size;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
color: globals.$body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
font-size: globals.$body-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.how-long-to-beat {
|
||||
&__categories-list {
|
||||
margin: 0;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 20%,
|
||||
rgb(255 255 255 / 2%) 100%
|
||||
);
|
||||
border-radius: 4px;
|
||||
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
|
||||
border: solid 1px globals.$border-color;
|
||||
}
|
||||
|
||||
&__category-label {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__category-skeleton {
|
||||
border: solid 1px globals.$border-color;
|
||||
border-radius: 4px;
|
||||
height: 76px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
&__section {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
justify-content: space-between;
|
||||
transition: max-height ease 0.5s;
|
||||
overflow: hidden;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
&__category-title {
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__category {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.1s;
|
||||
color: globals.$muted-color;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
padding: globals.$spacing-unit;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__item-image {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
|
||||
&--locked {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subscription-required-button {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
color: globals.$warning-color;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
|
||||
import {
|
||||
@@ -20,10 +19,10 @@ import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./sidebar.scss";
|
||||
|
||||
const fakeAchievements: UserAchievement[] = [
|
||||
const achievementsPlaceholder: UserAchievement[] = [
|
||||
{
|
||||
displayName: "Timber!!",
|
||||
name: "",
|
||||
@@ -64,7 +63,6 @@ export function Sidebar() {
|
||||
}>({ isLoading: true, data: null });
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
@@ -72,10 +70,8 @@ export function Sidebar() {
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,7 +114,7 @@ export function Sidebar() {
|
||||
}, [objectId, shop, gameTitle]);
|
||||
|
||||
return (
|
||||
<aside className={styles.contentSidebar}>
|
||||
<aside className="content-sidebar">
|
||||
{userDetails === null && (
|
||||
<SidebarSection title={t("achievements")}>
|
||||
<div
|
||||
@@ -133,21 +129,21 @@ export function Sidebar() {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<LockIcon size={36} />
|
||||
<h3>{t("sign_in_to_see_achievements")}</h3>
|
||||
</div>
|
||||
<ul className={styles.list} style={{ filter: "blur(4px)" }}>
|
||||
{fakeAchievements.map((achievement, index) => (
|
||||
<li key={index}>
|
||||
<div className={styles.listItem}>
|
||||
<ul className="list" style={{ filter: "blur(4px)" }}>
|
||||
{achievementsPlaceholder.map((achievement) => (
|
||||
<li key={achievement.displayName}>
|
||||
<div className="list__item">
|
||||
<img
|
||||
style={{ filter: "blur(8px)" }}
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
className={`list__item-image ${
|
||||
achievement.unlocked ? "" : "list__item-image--locked"
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
/>
|
||||
@@ -164,6 +160,7 @@ export function Sidebar() {
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{userDetails && achievements && achievements.length > 0 && (
|
||||
<SidebarSection
|
||||
title={t("achievements_count", {
|
||||
@@ -171,10 +168,10 @@ export function Sidebar() {
|
||||
achievementsCount: achievements.length,
|
||||
})}
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
<ul className="list">
|
||||
{!hasActiveSubscription && (
|
||||
<button
|
||||
className={styles.subscriptionRequiredButton}
|
||||
className="subscription-required-button"
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
>
|
||||
<CloudOfflineIcon size={16} />
|
||||
@@ -182,21 +179,21 @@ export function Sidebar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{achievements.slice(0, 4).map((achievement, index) => (
|
||||
<li key={index}>
|
||||
{achievements.slice(0, 4).map((achievement) => (
|
||||
<li key={achievement.displayName}>
|
||||
<Link
|
||||
to={buildGameAchievementPath({
|
||||
shop: shop,
|
||||
objectId: objectId!,
|
||||
title: gameTitle,
|
||||
})}
|
||||
className={styles.listItem}
|
||||
className="list__item"
|
||||
title={achievement.description}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
className={`list__item-image ${
|
||||
achievement.unlocked ? "" : "list__item-image--locked"
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
/>
|
||||
@@ -212,7 +209,6 @@ export function Sidebar() {
|
||||
))}
|
||||
|
||||
<Link
|
||||
style={{ textAlign: "center" }}
|
||||
to={buildGameAchievementPath({
|
||||
shop: shop,
|
||||
objectId: objectId!,
|
||||
@@ -227,17 +223,17 @@ export function Sidebar() {
|
||||
|
||||
{stats && (
|
||||
<SidebarSection title={t("stats")}>
|
||||
<div className={styles.statsSection}>
|
||||
<div className={styles.statsCategory}>
|
||||
<p className={styles.statsCategoryTitle}>
|
||||
<div className="stats__section">
|
||||
<div className="stats__category">
|
||||
<p className="stats__category-title">
|
||||
<DownloadIcon size={18} />
|
||||
{t("download_count")}
|
||||
</p>
|
||||
<p>{numberFormatter.format(stats?.downloadCount)}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.statsCategory}>
|
||||
<p className={styles.statsCategoryTitle}>
|
||||
<div className="stats__category">
|
||||
<p className="stats__category-title">
|
||||
<PeopleIcon size={18} />
|
||||
{t("player_count")}
|
||||
</p>
|
||||
@@ -253,9 +249,9 @@ export function Sidebar() {
|
||||
/>
|
||||
|
||||
<SidebarSection title={t("requirements")}>
|
||||
<div className={styles.requirementButtonContainer}>
|
||||
<div className="requirement__button-container">
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
className="requirement__button"
|
||||
onClick={() => setActiveRequirement("minimum")}
|
||||
theme={activeRequirement === "minimum" ? "primary" : "outline"}
|
||||
>
|
||||
@@ -263,7 +259,7 @@ export function Sidebar() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
className="requirement__button"
|
||||
onClick={() => setActiveRequirement("recommended")}
|
||||
theme={activeRequirement === "recommended" ? "primary" : "outline"}
|
||||
>
|
||||
@@ -272,7 +268,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.requirementsDetails}
|
||||
className="requirement__details"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
shopDetails?.pc_requirements?.[activeRequirement] ??
|
||||
|
||||
@@ -7,7 +7,6 @@ import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import * as styles from "./profile-content.css";
|
||||
import { TelescopeIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
import { FriendsBox } from "./friends-box";
|
||||
@@ -66,8 +65,6 @@ export function ProfileContent() {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
@@ -148,7 +145,6 @@ export function ProfileContent() {
|
||||
userStats,
|
||||
numberFormatter,
|
||||
t,
|
||||
navigate,
|
||||
statsIndex,
|
||||
]);
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export function ProfileHero() {
|
||||
if (gameRunning)
|
||||
return {
|
||||
...gameRunning,
|
||||
objectId: gameRunning.objectID,
|
||||
objectId: gameRunning.objectId,
|
||||
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SettingsAccount() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchUserDetails, updateUserDetails]);
|
||||
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
|
||||
@@ -28,13 +28,15 @@ export function SettingsBehavior() {
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding,
|
||||
runAtStartup: userPreferences.runAtStartup,
|
||||
startMinimized: userPreferences.startMinimized,
|
||||
disableNsfwAlert: userPreferences.disableNsfwAlert,
|
||||
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
|
||||
preferQuitInsteadOfHiding:
|
||||
userPreferences.preferQuitInsteadOfHiding ?? false,
|
||||
runAtStartup: userPreferences.runAtStartup ?? false,
|
||||
startMinimized: userPreferences.startMinimized ?? false,
|
||||
disableNsfwAlert: userPreferences.disableNsfwAlert ?? false,
|
||||
seedAfterDownloadComplete:
|
||||
userPreferences.seedAfterDownloadComplete ?? false,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription,
|
||||
userPreferences.showHiddenAchievementsDescription ?? false,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -57,10 +57,32 @@ export function SettingsGeneral() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(updateFormWithUserPreferences, [
|
||||
userPreferences,
|
||||
defaultDownloadsPath,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
const languageKeys = Object.keys(languageResources);
|
||||
const language =
|
||||
languageKeys.find(
|
||||
(language) => language === userPreferences.language
|
||||
) ??
|
||||
languageKeys.find((language) => {
|
||||
return language.startsWith(
|
||||
userPreferences.language?.split("-")[0] ?? "en"
|
||||
);
|
||||
});
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
|
||||
downloadNotificationsEnabled:
|
||||
userPreferences.downloadNotificationsEnabled ?? false,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences.repackUpdatesNotificationsEnabled ?? false,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled ?? false,
|
||||
language: language ?? "en",
|
||||
}));
|
||||
}
|
||||
}, [userPreferences, defaultDownloadsPath]);
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const value = event.target.value;
|
||||
@@ -86,31 +108,6 @@ export function SettingsGeneral() {
|
||||
}
|
||||
};
|
||||
|
||||
function updateFormWithUserPreferences() {
|
||||
if (userPreferences) {
|
||||
const languageKeys = Object.keys(languageResources);
|
||||
const language =
|
||||
languageKeys.find((language) => {
|
||||
return language === userPreferences.language;
|
||||
}) ??
|
||||
languageKeys.find((language) => {
|
||||
return language.startsWith(userPreferences.language.split("-")[0]);
|
||||
});
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
|
||||
downloadNotificationsEnabled:
|
||||
userPreferences.downloadNotificationsEnabled,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences.repackUpdatesNotificationsEnabled,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled,
|
||||
language: language ?? "en",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
|
||||
@@ -57,7 +57,8 @@ export function SettingsRealDebrid() {
|
||||
return;
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("real_debrid_linked_message", { username: user.username })
|
||||
t("real_debrid_account_linked"),
|
||||
t("debrid_linked_message", { username: user.username })
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -68,7 +69,7 @@ export function SettingsRealDebrid() {
|
||||
realDebridApiToken: form.useRealDebrid ? form.realDebridApiToken : null,
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(t("real_debrid_invalid_token"));
|
||||
showErrorToast(t("debrid_invalid_token"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -94,29 +95,30 @@ export function SettingsRealDebrid() {
|
||||
|
||||
{form.useRealDebrid && (
|
||||
<TextField
|
||||
label={t("real_debrid_api_token")}
|
||||
label={t("api_token")}
|
||||
value={form.realDebridApiToken ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, realDebridApiToken: event.target.value })
|
||||
}
|
||||
placeholder="API Token"
|
||||
containerProps={{ style: { marginTop: `${SPACING_UNIT}px` } }}
|
||||
containerProps={{
|
||||
style: {
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
},
|
||||
}}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
hint={
|
||||
<Trans i18nKey="real_debrid_api_token_hint" ns="settings">
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={REAL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
style={{ alignSelf: "flex-end", marginTop: `${SPACING_UNIT * 2}px` }}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/renderer/src/pages/settings/settings-torbox.css.ts
Normal file
13
src/renderer/src/pages/settings/settings-torbox.css.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
|
||||
export const form = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
116
src/renderer/src/pages/settings/settings-torbox.tsx
Normal file
116
src/renderer/src/pages/settings/settings-torbox.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import * as styles from "./settings-torbox.css";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
const TORBOX_API_TOKEN_URL = "https://torbox.app/settings";
|
||||
|
||||
export function SettingsTorbox() {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useTorBox: false,
|
||||
torBoxApiToken: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useTorBox: Boolean(userPreferences.torBoxApiToken),
|
||||
torBoxApiToken: userPreferences.torBoxApiToken ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
if (form.useTorBox) {
|
||||
const user = await window.electron.authenticateTorBox(
|
||||
form.torBoxApiToken!
|
||||
);
|
||||
|
||||
showSuccessToast(
|
||||
t("torbox_account_linked"),
|
||||
t("debrid_linked_message", { username: user.email })
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
torBoxApiToken: form.useTorBox ? form.torBoxApiToken : null,
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(t("debrid_invalid_token"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
(form.useTorBox && !form.torBoxApiToken) || isLoading;
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleFormSubmit}>
|
||||
<p className={styles.description}>{t("torbox_description")}</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_torbox")}
|
||||
checked={form.useTorBox}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useTorBox: !form.useTorBox,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useTorBox && (
|
||||
<TextField
|
||||
label={t("api_token")}
|
||||
value={form.torBoxApiToken ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, torBoxApiToken: event.target.value })
|
||||
}
|
||||
placeholder="API Token"
|
||||
containerProps={{
|
||||
style: {
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
},
|
||||
}}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
hint={
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={TORBOX_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
|
||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
import {
|
||||
SettingsContextConsumer,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { SettingsAccount } from "./settings-account";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { SettingsTorbox } from "./settings-torbox";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -22,13 +23,26 @@ export default function Settings() {
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categories = [
|
||||
t("general"),
|
||||
t("behavior"),
|
||||
t("download_sources"),
|
||||
"Real-Debrid",
|
||||
{ tabLabel: t("general"), contentTitle: t("general") },
|
||||
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
|
||||
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
|
||||
{
|
||||
tabLabel: (
|
||||
<>
|
||||
<img src={torBoxLogo} alt="TorBox" style={{ width: 13 }} />
|
||||
Torbox
|
||||
</>
|
||||
),
|
||||
contentTitle: "TorBox",
|
||||
},
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
];
|
||||
|
||||
if (userDetails) return [...categories, t("account")];
|
||||
if (userDetails)
|
||||
return [
|
||||
...categories,
|
||||
{ tabLabel: t("account"), contentTitle: t("account") },
|
||||
];
|
||||
return categories;
|
||||
}, [userDetails, t]);
|
||||
|
||||
@@ -50,6 +64,10 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 3) {
|
||||
return <SettingsTorbox />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 4) {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
@@ -62,18 +80,18 @@ export default function Settings() {
|
||||
<section className={styles.settingsCategories}>
|
||||
{categories.map((category, index) => (
|
||||
<Button
|
||||
key={category}
|
||||
key={index}
|
||||
theme={
|
||||
currentCategoryIndex === index ? "primary" : "outline"
|
||||
}
|
||||
onClick={() => setCurrentCategoryIndex(index)}
|
||||
>
|
||||
{category}
|
||||
{category.tabLabel}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<h2>{categories[currentCategoryIndex]}</h2>
|
||||
<h2>{categories[currentCategoryIndex].contentTitle}</h2>
|
||||
{renderCategory()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,3 +19,8 @@ $bottom-panel-z-index: 3;
|
||||
$title-bar-z-index: 4;
|
||||
$backdrop-z-index: 4;
|
||||
$modal-z-index: 5;
|
||||
|
||||
$body-font-size: 14px;
|
||||
$small-font-size: 12px;
|
||||
|
||||
$app-container: app-container;
|
||||
|
||||
@@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", {
|
||||
zIndex: {
|
||||
toast: "5",
|
||||
bottomPanel: "3",
|
||||
titleBar: "4",
|
||||
titleBar: "1900000001",
|
||||
backdrop: "4",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user