mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-23 10:51:02 +00:00
feat: removing crypto from level
This commit is contained in:
@@ -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 {
|
||||
@@ -85,7 +85,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onDownloadProgress(
|
||||
(downloadProgress) => {
|
||||
if (downloadProgress.progress === 1) {
|
||||
if (downloadProgress?.progress === 1) {
|
||||
clearDownload();
|
||||
updateLibrary();
|
||||
return;
|
||||
@@ -245,6 +245,22 @@ export function App() {
|
||||
loadAndApplyTheme();
|
||||
}, []);
|
||||
|
||||
const playAudio = useCallback(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.2;
|
||||
audio.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(() => {
|
||||
playAudio();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [playAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onCssInjected((cssString) => {
|
||||
if (cssString) {
|
||||
@@ -252,9 +268,7 @@ export function App() {
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
@@ -276,9 +290,11 @@ export function App() {
|
||||
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
title={toast.title}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleToastClose}
|
||||
duration={toast.duration}
|
||||
/>
|
||||
|
||||
<HydraCloudModal
|
||||
|
||||
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 |
@@ -7,5 +7,6 @@
|
||||
border: solid 1px globals.$muted-color;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export function BottomPanel() {
|
||||
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const isGameDownloading = !!lastPacket;
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
const [sessionHash, setSessionHash] = useState<null | string>("");
|
||||
|
||||
@@ -33,9 +31,11 @@ export function BottomPanel() {
|
||||
}, [userDetails?.id]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isGameDownloading) {
|
||||
const game = library.find((game) => game.id === lastPacket?.gameId)!;
|
||||
const game = lastPacket
|
||||
? library.find((game) => game.id === lastPacket?.gameId)
|
||||
: undefined;
|
||||
|
||||
if (game) {
|
||||
if (lastPacket?.isCheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: game.title,
|
||||
@@ -64,7 +64,7 @@ export function BottomPanel() {
|
||||
}
|
||||
|
||||
return t("no_downloads_in_progress");
|
||||
}, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]);
|
||||
}, [t, library, lastPacket, progress, eta, downloadSpeed]);
|
||||
|
||||
return (
|
||||
<footer className="bottom-panel">
|
||||
@@ -76,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
&__checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
border-radius: 4px;
|
||||
background-color: globals.$dark-background-color;
|
||||
display: flex;
|
||||
@@ -45,6 +47,9 @@
|
||||
|
||||
&__label {
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&:has(+ input:disabled) {
|
||||
cursor: not-allowed;
|
||||
|
||||
50
src/renderer/src/components/sidebar/sidebar-game-item.tsx
Normal file
50
src/renderer/src/components/sidebar/sidebar-game-item.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface SidebarGameItemProps {
|
||||
game: LibraryGame;
|
||||
handleSidebarGameClick: (event: React.MouseEvent, game: LibraryGame) => void;
|
||||
getGameTitle: (game: LibraryGame) => string;
|
||||
}
|
||||
|
||||
export function SidebarGameItem({
|
||||
game,
|
||||
handleSidebarGameClick,
|
||||
getGameTitle,
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname === `/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted": game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,11 @@ 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";
|
||||
import { SidebarGameItem } from "./sidebar-game-item";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -206,6 +206,23 @@ export function Sidebar() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("favorites")}</small>
|
||||
|
||||
<ul className="sidebar__menu">
|
||||
{sortedLibrary
|
||||
.filter((game) => game.favorite)
|
||||
.map((game) => (
|
||||
<SidebarGameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
handleSidebarGameClick={handleSidebarGameClick}
|
||||
getGameTitle={getGameTitle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("my_library")}</small>
|
||||
|
||||
@@ -217,39 +234,16 @@ export function Sidebar() {
|
||||
/>
|
||||
|
||||
<ul className="sidebar__menu">
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
key={`${game.shop}-${game.objectId}`}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname ===
|
||||
`/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted":
|
||||
game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{filteredLibrary
|
||||
.filter((game) => !game.favorite)
|
||||
.map((game) => (
|
||||
<SidebarGameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
handleSidebarGameClick={handleSidebarGameClick}
|
||||
getGameTitle={getGameTitle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -55,11 +55,9 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
}, [props.type, isPasswordVisible]);
|
||||
|
||||
const hintContent = useMemo(() => {
|
||||
if (error && typeof error === "object" && "message" in error)
|
||||
if (error)
|
||||
return (
|
||||
<small className="text-field-container__error-label">
|
||||
{error.message as string}
|
||||
</small>
|
||||
<small className="text-field-container__error-label">{error}</small>
|
||||
);
|
||||
|
||||
if (hint) return <small>{hint}</small>;
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
$toast-height: 80px;
|
||||
|
||||
.toast {
|
||||
animation-duration: 0.2s;
|
||||
animation-timing-function: ease-in-out;
|
||||
max-height: $toast-height;
|
||||
position: fixed;
|
||||
background-color: globals.$background-color;
|
||||
position: absolute;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
right: calc(globals.$spacing-unit * 2);
|
||||
//bottom panel height + 16px
|
||||
bottom: calc(26px + #{globals.$spacing-unit * 2});
|
||||
right: 16px;
|
||||
bottom: 26px + globals.$spacing-unit;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
z-index: globals.$toast-z-index;
|
||||
max-width: 500px;
|
||||
animation-name: slide-in;
|
||||
max-width: 420px;
|
||||
animation-name: enter;
|
||||
transform: translateY(0);
|
||||
|
||||
&--closing {
|
||||
animation-name: slide-out;
|
||||
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
|
||||
animation-name: exit;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -38,6 +34,7 @@ $toast-height: 80px;
|
||||
&__message-container {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__message {
|
||||
@@ -62,37 +59,38 @@ $toast-height: 80px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__success-icon {
|
||||
color: globals.$success-color;
|
||||
}
|
||||
&__icon {
|
||||
&--success {
|
||||
color: globals.$success-color;
|
||||
}
|
||||
|
||||
&__error-icon {
|
||||
color: globals.$danger-color;
|
||||
}
|
||||
&--error {
|
||||
color: globals.$danger-color;
|
||||
}
|
||||
|
||||
&__warning-icon {
|
||||
color: globals.$warning-color;
|
||||
&--warning {
|
||||
color: globals.$warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
@keyframes enter {
|
||||
0% {
|
||||
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
100% {
|
||||
@keyframes exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,23 @@ 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,9 +75,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
setIsClosing(false);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [startAnimateClosing, visible]);
|
||||
}, [startAnimateClosing, duration, visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -84,26 +88,40 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
||||
>
|
||||
<div className="toast__content">
|
||||
<div className="toast__message-container">
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className="toast__success-icon" />
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: `8px`,
|
||||
}}
|
||||
>
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className="toast__icon--success" />
|
||||
)}
|
||||
|
||||
{type === "error" && (
|
||||
<XCircleFillIcon className="toast__error-icon" />
|
||||
)}
|
||||
{type === "error" && (
|
||||
<XCircleFillIcon className="toast__icon--error" />
|
||||
)}
|
||||
|
||||
{type === "warning" && <AlertIcon className="toast__warning-icon" />}
|
||||
<span className="toast__message">{message}</span>
|
||||
{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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="toast__close-button"
|
||||
onClick={startAnimateClosing}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<progress className="toast__progress" value={progress} max={100} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
src/renderer/src/declaration.d.ts
vendored
14
src/renderer/src/declaration.d.ts
vendored
@@ -28,6 +28,7 @@ import type {
|
||||
CatalogueSearchPayload,
|
||||
LibraryGame,
|
||||
GameRunning,
|
||||
TorBoxUser,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -40,14 +41,16 @@ declare global {
|
||||
|
||||
interface Electron {
|
||||
/* Torrenting */
|
||||
startGameDownload: (payload: StartGameDownloadPayload) => 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
|
||||
cb: (value: DownloadProgress | null) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onSeedingStatus: (
|
||||
cb: (value: SeedingStatus[]) => void
|
||||
@@ -93,6 +96,11 @@ declare global {
|
||||
objectId: string,
|
||||
executablePath: string | null
|
||||
) => Promise<void>;
|
||||
addGameToFavorites: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
removeGameFromFavorites: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
updateLaunchOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -142,6 +150,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: (
|
||||
|
||||
@@ -18,9 +18,9 @@ export const downloadSlice = createSlice({
|
||||
name: "download",
|
||||
initialState,
|
||||
reducers: {
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId) state.gameId = action.payload.gameId;
|
||||
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
|
||||
},
|
||||
clearDownload: (state) => {
|
||||
state.lastPacket = null;
|
||||
|
||||
@@ -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 ?? 2000;
|
||||
state.visible = true;
|
||||
},
|
||||
closeToast: (state) => {
|
||||
|
||||
@@ -29,10 +29,11 @@ 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 (shop: GameShop, objectId: string) => {
|
||||
@@ -113,7 +114,7 @@ export function useDownload() {
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
clearDownload: () => dispatch(clearDownload()),
|
||||
setLastPacket: (packet: DownloadProgress) =>
|
||||
setLastPacket: (packet: DownloadProgress | null) =>
|
||||
dispatch(setLastPacket(packet)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ Sentry.init({
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
release: await window.electron.getVersion(),
|
||||
});
|
||||
|
||||
console.log = logger.log;
|
||||
|
||||
@@ -10,7 +10,9 @@ 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();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.achievement-panel {
|
||||
width: 100%;
|
||||
padding: globals.$spacing-unit * 2 globals.$spacing-unit * 3;
|
||||
background-color: globals.$dark-background-color;
|
||||
background-color: globals.$background-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
@@ -66,9 +66,9 @@ $logo-max-width: 200px;
|
||||
|
||||
&__table-header {
|
||||
width: 100%;
|
||||
background-color: var(--color-dark-background);
|
||||
background-color: globals.$dark-background-color;
|
||||
transition: all ease 0.2s;
|
||||
border-bottom: solid 1px var(--color-border);
|
||||
border-bottom: solid 1px globals.$border-color;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -86,13 +86,13 @@ $logo-max-width: 200px;
|
||||
gap: globals.$spacing-unit * 2;
|
||||
padding: globals.$spacing-unit * 2;
|
||||
width: 100%;
|
||||
background-color: var(--color-background);
|
||||
background-color: globals.$background-color;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
transition: all ease 0.1s;
|
||||
color: var(--color-muted);
|
||||
color: globals.$muted-color;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
@@ -102,7 +102,7 @@ $logo-max-width: 200px;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
background-color: globals.$border-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ $logo-max-width: 200px;
|
||||
|
||||
&-hidden-icon {
|
||||
display: flex;
|
||||
color: var(--color-warning);
|
||||
color: globals.$warning-color;
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
@@ -164,7 +164,7 @@ $logo-max-width: 200px;
|
||||
|
||||
&--locked {
|
||||
cursor: pointer;
|
||||
color: var(--color-warning);
|
||||
color: globals.$warning-color;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
@@ -219,12 +219,12 @@ $logo-max-width: 200px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
background-color: globals.$border-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--color-muted);
|
||||
background-color: globals.$muted-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ $logo-max-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
background-color: globals.$background-color;
|
||||
position: relative;
|
||||
object-fit: cover;
|
||||
|
||||
@@ -252,7 +252,7 @@ $logo-max-width: 200px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: math.div(globals.$spacing-unit, 2);
|
||||
color: var(--color-body);
|
||||
color: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
@@ -235,12 +237,16 @@ export function DownloadGroup({
|
||||
];
|
||||
}
|
||||
|
||||
const isResumeDisabled =
|
||||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("resume"),
|
||||
disabled:
|
||||
download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken,
|
||||
disabled: isResumeDisabled,
|
||||
onClick: () => {
|
||||
resumeDownload(game.shop, game.objectId);
|
||||
},
|
||||
@@ -279,13 +285,20 @@ export function DownloadGroup({
|
||||
/>
|
||||
|
||||
<div className="download-group__cover-content">
|
||||
<Badge>
|
||||
{
|
||||
DOWNLOADER_NAME[
|
||||
game?.download?.downloader as Downloader
|
||||
]
|
||||
}
|
||||
</Badge>
|
||||
{game.download?.downloader === Downloader.TorBox ? (
|
||||
<Badge>
|
||||
<img
|
||||
src={torBoxLogo}
|
||||
alt="TorBox"
|
||||
style={{ width: 13 }}
|
||||
/>
|
||||
<span>TorBox</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[game.download!.downloader]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import "./downloads.scss";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { orderBy, sortBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
export default function Downloads() {
|
||||
@@ -58,21 +58,24 @@ 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.download?.status || next.download?.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?.gameId === 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.download.queued || next.download?.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);
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
},
|
||||
initialValue
|
||||
);
|
||||
|
||||
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
|
||||
"desc",
|
||||
|
||||
@@ -189,14 +189,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploadingBackup && (
|
||||
<progress
|
||||
className="cloud-sync-modal__progress"
|
||||
value={backupDownloadProgress?.progress ?? 0}
|
||||
max={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="cloud-sync-modal__backups-header">
|
||||
<h2>{t("backups")}</h2>
|
||||
<small>
|
||||
|
||||
@@ -139,6 +139,7 @@ export function GameDetailsContent() {
|
||||
className="game-details__hero-backdrop"
|
||||
style={{
|
||||
backgroundColor: gameColor,
|
||||
flex: 1,
|
||||
opacity: Math.min(1, 1 - backdropOpactiy),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -100,19 +100,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 = () => {
|
||||
@@ -121,10 +125,7 @@ export default function GameDetails() {
|
||||
};
|
||||
|
||||
return (
|
||||
<CloudSyncContextProvider
|
||||
objectId={objectId!}
|
||||
shop={shop! as GameShop}
|
||||
>
|
||||
<CloudSyncContextProvider objectId={objectId!} shop={shop}>
|
||||
<CloudSyncContextConsumer>
|
||||
{({
|
||||
showCloudSyncModal,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
DownloadIcon,
|
||||
GearIcon,
|
||||
HeartFillIcon,
|
||||
HeartIcon,
|
||||
PlayIcon,
|
||||
PlusCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./hero-panel-actions.scss";
|
||||
|
||||
export function HeroPanelActions() {
|
||||
@@ -37,6 +40,8 @@ export function HeroPanelActions() {
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const addGameToLibrary = async () => {
|
||||
@@ -52,6 +57,29 @@ export function HeroPanelActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGameFavorite = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (game?.favorite) {
|
||||
await window.electron
|
||||
.removeGameFromFavorites(shop, objectId!)
|
||||
.then(() => {
|
||||
showSuccessToast(t("game_removed_from_favorites"));
|
||||
});
|
||||
} else {
|
||||
await window.electron.addGameToFavorites(shop, objectId!).then(() => {
|
||||
showSuccessToast(t("game_added_to_favorites"));
|
||||
});
|
||||
}
|
||||
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openGame = async () => {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
@@ -159,6 +187,15 @@ export function HeroPanelActions() {
|
||||
<div className="hero-panel-actions__container">
|
||||
{gameActionButton()}
|
||||
<div className="hero-panel-actions__separator" />
|
||||
<Button
|
||||
onClick={toggleGameFavorite}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className="hero-panel-actions__action"
|
||||
>
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface DownloadSettingsModalProps {
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function DownloadSettingsModal({
|
||||
onClose,
|
||||
startDownload,
|
||||
repack,
|
||||
}: DownloadSettingsModalProps) {
|
||||
}: Readonly<DownloadSettingsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { showErrorToast } = useToast();
|
||||
@@ -85,19 +85,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,
|
||||
@@ -116,20 +114,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
}: GameOptionsModalProps) {
|
||||
}: Readonly<GameOptionsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
@@ -183,8 +183,6 @@ export function GameOptionsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowLaunchOptionsConfiguration = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteGameModal
|
||||
@@ -192,12 +190,14 @@ export function GameOptionsModal({
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
deleteGame={handleDeleteGame}
|
||||
/>
|
||||
|
||||
<RemoveGameFromLibraryModal
|
||||
visible={showRemoveGameModal}
|
||||
onClose={() => setShowRemoveGameModal(false)}
|
||||
removeGameFromLibrary={handleRemoveGameFromLibrary}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<ResetAchievementsModal
|
||||
visible={showResetAchievementsModal}
|
||||
onClose={() => setShowResetAchievementsModal(false)}
|
||||
@@ -304,29 +304,27 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowLaunchOptionsConfiguration && (
|
||||
<div className="game-options-modal__launch-options">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("launch_options")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("launch_options_description")}
|
||||
</h4>
|
||||
</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 className="game-options-modal__launch-options">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("launch_options")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("launch_options_description")}
|
||||
</h4>
|
||||
</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="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface RepacksModalProps {
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,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);
|
||||
@@ -107,7 +107,7 @@ export function RepacksModal({
|
||||
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -164,8 +164,8 @@ 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,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function ProfileContent() {
|
||||
}
|
||||
|
||||
const hasGames = userProfile?.libraryGames.length > 0;
|
||||
|
||||
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
|
||||
|
||||
return (
|
||||
@@ -99,6 +100,7 @@ export function ProfileContent() {
|
||||
<>
|
||||
<div className="profile-content__section-header">
|
||||
<h2>{t("library")}</h2>
|
||||
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
||||
)}
|
||||
@@ -142,6 +144,7 @@ export function ProfileContent() {
|
||||
return (
|
||||
<div>
|
||||
<ProfileHero />
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,9 @@ import "./recent-games-box.scss";
|
||||
|
||||
export function RecentGamesBox() {
|
||||
const { userProfile } = useContext(userProfileContext);
|
||||
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
|
||||
@@ -61,7 +61,9 @@ export function UserLibraryGameCard({
|
||||
|
||||
const formatAchievementPoints = (number: number) => {
|
||||
if (number < 100_000) return numberFormatter.format(number);
|
||||
|
||||
if (number < 1_000_000) return `${(number / 1000).toFixed(1)}K`;
|
||||
|
||||
return `${(number / 1_000_000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ export function ProfileHero() {
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
handleFriendAction(userProfile.relation!.AId, "CANCEL")
|
||||
handleFriendAction(userProfile.relation!.BId, "CANCEL")
|
||||
}
|
||||
disabled={isPerformingAction}
|
||||
className="profile-hero__button--outline"
|
||||
|
||||
@@ -10,9 +10,12 @@ export function UploadBackgroundImageButton() {
|
||||
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
|
||||
useState(false);
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
|
||||
const { patchUser, fetchUserDetails } = useUserDetails();
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const handleChangeCoverClick = async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function SettingsAccount() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchUserDetails, updateUserDetails]);
|
||||
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
|
||||
@@ -29,13 +29,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]);
|
||||
@@ -83,6 +85,7 @@ export function SettingsBehavior() {
|
||||
>
|
||||
<CheckboxField
|
||||
label={t("launch_minimized")}
|
||||
style={{ cursor: form.runAtStartup ? "pointer" : "not-allowed" }}
|
||||
checked={form.runAtStartup && form.startMinimized}
|
||||
disabled={!form.runAtStartup}
|
||||
onChange={() => {
|
||||
|
||||
@@ -68,18 +68,20 @@ export function SettingsGeneral() {
|
||||
(language) => language === userPreferences.language
|
||||
) ??
|
||||
languageKeys.find((language) => {
|
||||
return language.startsWith(userPreferences.language.split("-")[0]);
|
||||
return language.startsWith(
|
||||
userPreferences.language?.split("-")[0] ?? "en"
|
||||
);
|
||||
});
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
|
||||
downloadNotificationsEnabled:
|
||||
userPreferences.downloadNotificationsEnabled,
|
||||
userPreferences.downloadNotificationsEnabled ?? false,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences.repackUpdatesNotificationsEnabled,
|
||||
userPreferences.repackUpdatesNotificationsEnabled ?? false,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled,
|
||||
userPreferences.achievementNotificationsEnabled ?? false,
|
||||
language: language ?? "en",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -56,7 +56,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 {
|
||||
@@ -67,7 +68,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);
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export function SettingsRealDebrid() {
|
||||
|
||||
{form.useRealDebrid && (
|
||||
<TextField
|
||||
label={t("real_debrid_api_token")}
|
||||
label={t("api_token")}
|
||||
value={form.realDebridApiToken ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
@@ -108,7 +109,7 @@ export function SettingsRealDebrid() {
|
||||
}
|
||||
placeholder="API Token"
|
||||
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>
|
||||
}
|
||||
|
||||
18
src/renderer/src/pages/settings/settings-torbox.scss
Normal file
18
src/renderer/src/pages/settings/settings-torbox.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.settings-torbox {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__submit-button {
|
||||
align-self: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
110
src/renderer/src/pages/settings/settings-torbox.tsx
Normal file
110
src/renderer/src/pages/settings/settings-torbox.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import "./settings-torbox.scss";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
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="settings-torbox__form" onSubmit={handleFormSubmit}>
|
||||
<p className="settings-torbox__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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,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,
|
||||
@@ -13,21 +14,39 @@ import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import "./settings.scss";
|
||||
import { SettingsAppearance } from "./aparence/settings-appearance";
|
||||
import { SettingsTorbox } from "./settings-torbox";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categories = [
|
||||
t("general"),
|
||||
t("behavior"),
|
||||
t("download_sources"),
|
||||
t("appearance"),
|
||||
"Real-Debrid",
|
||||
{ tabLabel: t("general"), contentTitle: t("general") },
|
||||
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
|
||||
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
|
||||
{
|
||||
tabLabel: t("appearance"),
|
||||
contentTitle: t("appearance"),
|
||||
},
|
||||
{
|
||||
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]);
|
||||
|
||||
@@ -53,6 +72,10 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 4) {
|
||||
return <SettingsTorbox />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 5) {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
@@ -65,18 +88,18 @@ export default function Settings() {
|
||||
<section className="settings__categories">
|
||||
{categories.map((category, index) => (
|
||||
<Button
|
||||
key={category}
|
||||
key={category.contentTitle}
|
||||
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>
|
||||
|
||||
@@ -30,9 +30,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
|
||||
|
||||
const getRequestDescription = () => {
|
||||
if (type === "ACCEPTED" || type === null) return null;
|
||||
|
||||
return (
|
||||
<small>
|
||||
{type === "SENT" ? t("request_sent") : t("request_received")}
|
||||
{type == "SENT" ? t("request_sent") : t("request_received")}
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,11 +26,15 @@ export const UserFriendModal = ({
|
||||
userId,
|
||||
}: UserFriendsModalProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const tabs = [t("friends_list"), t("add_friends")];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(
|
||||
initialTab || UserFriendModalTab.FriendsList
|
||||
);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
const isMe = userDetails?.id == userId;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user