mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 16:53:57 +00:00
Merge branch 'main' into hotfix-game-minimun-specs-accordion
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<title>Hydra</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' * data:;"
|
||||
content="default-src 'self' 'unsafe-inline' * data: local:;"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useRepacks,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
@@ -15,8 +16,6 @@ import * as styles from "./app.css";
|
||||
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
setSearch,
|
||||
clearSearch,
|
||||
setUserPreferences,
|
||||
toggleDraggingDisabled,
|
||||
closeToast,
|
||||
@@ -27,8 +26,9 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { repacksContext } from "./context";
|
||||
import { logger } from "./logger";
|
||||
import { downloadSourcesTable } from "./dexie";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -40,35 +40,31 @@ export function App() {
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const downloadSourceMigrationLock = useRef(false);
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
hasActiveSubscription,
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
friendModalUserId,
|
||||
syncFriendRequests,
|
||||
hideFriendsModal,
|
||||
} = useUserDetails();
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
hasActiveSubscription,
|
||||
fetchUserDetails,
|
||||
updateUserDetails,
|
||||
clearUserDetails,
|
||||
} = useUserDetails();
|
||||
|
||||
const { hideHydraCloudModal, isHydraCloudModalVisible, hydraCloudFeature } =
|
||||
useSubscription();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const search = useAppSelector((state) => state.search.value);
|
||||
|
||||
const draggingDisabled = useAppSelector(
|
||||
(state) => state.window.draggingDisabled
|
||||
);
|
||||
@@ -103,6 +99,14 @@ export function App() {
|
||||
};
|
||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onHardDelete(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||
|
||||
@@ -126,7 +130,7 @@ export function App() {
|
||||
|
||||
const $script = document.createElement("script");
|
||||
$script.id = "external-resources";
|
||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}?t=${Date.now()}`;
|
||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
||||
document.head.appendChild($script);
|
||||
});
|
||||
}, [fetchUserDetails, syncFriendRequests, updateUserDetails, dispatch]);
|
||||
@@ -187,31 +191,6 @@ export function App() {
|
||||
};
|
||||
}, [onSignIn, updateLibrary, clearUserDetails]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
dispatch(setSearch(query));
|
||||
|
||||
if (query === "") {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
query,
|
||||
});
|
||||
|
||||
navigate(`/search?${searchParams.toString()}`, {
|
||||
replace: location.pathname.startsWith("/search"),
|
||||
});
|
||||
},
|
||||
[dispatch, location.pathname, navigate]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
dispatch(clearSearch());
|
||||
navigate(-1);
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
}, [location.pathname, location.search]);
|
||||
@@ -228,53 +207,31 @@ export function App() {
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadSourceMigrationLock.current) return;
|
||||
updateRepacks();
|
||||
|
||||
downloadSourceMigrationLock.current = true;
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
window.electron.getDownloadSources().then(async (downloadSources) => {
|
||||
if (!downloadSources.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
updateRepacks();
|
||||
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
};
|
||||
downloadSourcesTable.toArray().then((downloadSources) => {
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.forEach((downloadSource) => {
|
||||
window.electron
|
||||
.putDownloadSource(downloadSource.objectIds)
|
||||
.then(({ fingerprint }) => {
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
logger.info("Migrating download source", downloadSource.url);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:import:${downloadSource.url}`
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
downloadSource.url,
|
||||
]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
||||
resolve(true);
|
||||
logger.info(
|
||||
"Deleted download source from SQLite",
|
||||
downloadSource.url
|
||||
);
|
||||
});
|
||||
|
||||
indexRepacks();
|
||||
channel.close();
|
||||
};
|
||||
}).catch(() => channel.close());
|
||||
}
|
||||
|
||||
downloadSourceMigrationLock.current = false;
|
||||
});
|
||||
}, [indexRepacks]);
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}, [updateRepacks]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
@@ -300,6 +257,12 @@ export function App() {
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
|
||||
<HydraCloudModal
|
||||
visible={isHydraCloudModalVisible}
|
||||
onClose={hideHydraCloudModal}
|
||||
feature={hydraCloudFeature}
|
||||
/>
|
||||
|
||||
{userDetails && (
|
||||
<UserFriendModal
|
||||
visible={isFriendsModalVisible}
|
||||
@@ -313,11 +276,7 @@ export function App() {
|
||||
<Sidebar />
|
||||
|
||||
<article className={styles.container}>
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
search={search}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<Outlet />
|
||||
|
||||
13
src/renderer/src/assets/icons/hydra.svg
Normal file
13
src/renderer/src/assets/icons/hydra.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
|
||||
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
|
||||
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
|
||||
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
|
||||
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
|
||||
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
|
||||
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
|
||||
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
|
||||
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
|
||||
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
|
||||
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -7,9 +7,5 @@ export interface BadgeProps {
|
||||
}
|
||||
|
||||
export function Badge({ children }: BadgeProps) {
|
||||
return (
|
||||
<div className="badge">
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
return <div className="badge">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const checkboxField = style({
|
||||
display: "flex",
|
||||
@@ -10,19 +11,31 @@ export const checkboxField = style({
|
||||
cursor: "pointer",
|
||||
});
|
||||
|
||||
export const checkbox = style({
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
export const checkbox = recipe({
|
||||
base: {
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
minWidth: "20px",
|
||||
minHeight: "20px",
|
||||
color: vars.color.darkBackground,
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
checked: {
|
||||
true: {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,4 +51,7 @@ export const checkboxInput = style({
|
||||
|
||||
export const checkboxLabel = style({
|
||||
cursor: "pointer",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxField}>
|
||||
<div className={styles.checkbox}>
|
||||
<div className={styles.checkbox({ checked: props.checked })}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
|
||||
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.dropdown-menu {
|
||||
&__content {
|
||||
background-color: globals.$dark-background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 6px;
|
||||
min-width: 200px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__group {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__title-bar {
|
||||
width: 100%;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: globals.$border-color;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__item--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:not(&__item--disabled) &__item:hover {
|
||||
background-color: globals.$background-color;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__item:focus {
|
||||
background-color: globals.$background-color;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import "./dropdown-menu.scss";
|
||||
|
||||
export interface DropdownMenuItem {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
show?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
loop?: boolean;
|
||||
items: DropdownMenuItem[];
|
||||
sideOffset?: number;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
children,
|
||||
title,
|
||||
items,
|
||||
sideOffset = 5,
|
||||
side = "bottom",
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
}: DropdownMenuProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<div aria-label={title}>{children}</div>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
side={side}
|
||||
loop={loop}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
className="dropdown-menu__content"
|
||||
>
|
||||
{title && (
|
||||
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||
<div className="dropdown-menu__title-bar">{title}</div>
|
||||
</DropdownMenuPrimitive.Group>
|
||||
)}
|
||||
|
||||
<DropdownMenuPrimitive.Separator className="dropdown-menu__separator" />
|
||||
|
||||
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||
{items.map(
|
||||
(item) =>
|
||||
item.show !== false && (
|
||||
<DropdownMenuPrimitive.Item
|
||||
key={item.label}
|
||||
aria-label={item.label}
|
||||
onSelect={item.onClick}
|
||||
className={`dropdown-menu__item ${item.disabled ? "dropdown-menu__item--disabled" : ""}`}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<div className="dropdown-menu__item-icon">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
{item.label}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuPrimitive.Group>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
|
||||
import type { GameStats } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { repacksContext } from "@renderer/context";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
game: CatalogueEntry;
|
||||
game: any;
|
||||
}
|
||||
|
||||
const shopIcon = {
|
||||
@@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
const { t } = useTranslation("game_card");
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(game.title).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, isIndexingRepacks, searchRepacks]);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(repacks.map(({ repacker }) => repacker))
|
||||
new Set(repacks.map((repack) => repack.repacker))
|
||||
);
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
@@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
>
|
||||
<div className={styles.backdrop}>
|
||||
<img
|
||||
src={game.cover}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
className={styles.cover}
|
||||
loading="lazy"
|
||||
|
||||
@@ -6,14 +6,8 @@ import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./header.css";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
|
||||
export interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
onClear: () => void;
|
||||
search?: string;
|
||||
}
|
||||
import { setFilters } from "@renderer/features";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
@@ -22,7 +16,7 @@ const pathTitle: Record<string, string> = {
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -31,6 +25,11 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||
(state) => state.window
|
||||
);
|
||||
|
||||
const searchValue = useAppSelector(
|
||||
(state) => state.catalogueSearch.filters.title
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -46,12 +45,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
return t(pathTitle[location.pathname]);
|
||||
}, [location.pathname, headerTitle, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search && !location.pathname.startsWith("/search")) {
|
||||
dispatch(clearSearch());
|
||||
}
|
||||
}, [location.pathname, search, dispatch]);
|
||||
|
||||
const focusInput = () => {
|
||||
setIsFocused(true);
|
||||
inputRef.current?.focus();
|
||||
@@ -65,6 +58,20 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
dispatch(setFilters({ title: value }));
|
||||
|
||||
if (!location.pathname.startsWith("/catalogue")) {
|
||||
navigate("/catalogue");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith("/catalogue") && searchValue) {
|
||||
dispatch(setFilters({ title: "" }));
|
||||
}
|
||||
}, [location.pathname, searchValue, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -109,17 +116,17 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
value={searchValue}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onChange={(event) => handleSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{search && (
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
onClick={() => dispatch(setFilters({ title: "" }))}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<XIcon />
|
||||
|
||||
@@ -46,6 +46,12 @@ export function Modal({
|
||||
}, [onClose]);
|
||||
|
||||
const isTopMostModal = () => {
|
||||
if (
|
||||
document.querySelector(
|
||||
".featurebase-widget-overlay.featurebase-display-block"
|
||||
)
|
||||
)
|
||||
return false;
|
||||
const openModals = document.querySelectorAll("[role=dialog]");
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar } from "../avatar/avatar";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
const LONG_POLLING_INTERVAL = 120_000;
|
||||
|
||||
@@ -26,11 +27,11 @@ export function SidebarProfile() {
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (userDetails === null) {
|
||||
window.electron.openAuthWindow();
|
||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/profile/${userDetails!.id}`);
|
||||
navigate(`/profile/${userDetails.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -154,7 +154,11 @@ export function Sidebar() {
|
||||
|
||||
if (event.detail === 2) {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
game.executablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
} else {
|
||||
showWarningToast(t("game_has_no_executable"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useId, useMemo, useState } from "react";
|
||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import type { FieldError, FieldErrorsImpl, Merge } from "react-hook-form";
|
||||
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -23,7 +22,7 @@ export interface TextFieldProps
|
||||
HTMLDivElement
|
||||
>;
|
||||
rightContent?: React.ReactNode | null;
|
||||
error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
error?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
@@ -55,10 +54,7 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
}, [props.type, isPasswordVisible]);
|
||||
|
||||
const hintContent = useMemo(() => {
|
||||
if (error && error.message)
|
||||
return (
|
||||
<small className={styles.errorLabel}>{error.message as string}</small>
|
||||
);
|
||||
if (error) return <small className={styles.errorLabel}>{error}</small>;
|
||||
|
||||
if (hint) return <small>{hint}</small>;
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export const VERSION_CODENAME = "Skyscraper";
|
||||
export const VERSION_CODENAME = "Spectre";
|
||||
|
||||
export const DOWNLOADER_NAME = {
|
||||
[Downloader.RealDebrid]: "Real-Debrid",
|
||||
@@ -8,6 +8,7 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Gofile]: "Gofile",
|
||||
[Downloader.PixelDrain]: "PixelDrain",
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -14,12 +13,12 @@ import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useRepacks,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
ShopDetails,
|
||||
@@ -29,7 +28,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
import { repacksContext } from "../repacks/repacks.context";
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
@@ -53,7 +51,6 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
setShowGameOptionsModal: () => {},
|
||||
setShowRepacksModal: () => {},
|
||||
setHasNSFWContentBlocked: () => {},
|
||||
handleClickOpenCheckout: () => {},
|
||||
});
|
||||
|
||||
const { Provider } = gameDetailsContext;
|
||||
@@ -88,17 +85,8 @@ export function GameDetailsContextProvider({
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(gameTitle).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(objectId);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
@@ -111,11 +99,6 @@ export function GameDetailsContextProvider({
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const handleClickOpenCheckout = () => {
|
||||
// TODO: show modal before redirecting to checkout page
|
||||
window.electron.openCheckout();
|
||||
};
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectId(objectId!)
|
||||
@@ -134,11 +117,7 @@ export function GameDetailsContextProvider({
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectId!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
)
|
||||
.getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language))
|
||||
.then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
@@ -157,14 +136,14 @@ export function GameDetailsContextProvider({
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
window.electron.getGameStats(objectId, shop as GameShop).then((result) => {
|
||||
window.electron.getGameStats(objectId, shop).then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
if (userDetails) {
|
||||
window.electron
|
||||
.getUnlockedAchievements(objectId, shop as GameShop)
|
||||
.getUnlockedAchievements(objectId, shop)
|
||||
.then((achievements) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setAchievements(achievements);
|
||||
@@ -290,7 +269,6 @@ export function GameDetailsContextProvider({
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
setShowGameOptionsModal,
|
||||
handleClickOpenCheckout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -29,5 +29,4 @@ export interface GameDetailsContext {
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleClickOpenCheckout: () => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
export * from "./cloud-sync/cloud-sync.context";
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { GameRepack } from "@types";
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { repacksWorker } from "@renderer/workers";
|
||||
|
||||
export interface RepacksContext {
|
||||
searchRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
indexRepacks: () => void;
|
||||
isIndexingRepacks: boolean;
|
||||
}
|
||||
|
||||
export const repacksContext = createContext<RepacksContext>({
|
||||
searchRepacks: async () => [] as GameRepack[],
|
||||
indexRepacks: () => {},
|
||||
isIndexingRepacks: false,
|
||||
});
|
||||
|
||||
const { Provider } = repacksContext;
|
||||
export const { Consumer: RepacksContextConsumer } = repacksContext;
|
||||
|
||||
export interface RepacksContextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RepacksContextProvider({ children }: RepacksContextProps) {
|
||||
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
|
||||
|
||||
const searchRepacks = useCallback(async (query: string) => {
|
||||
return new Promise<GameRepack[]>((resolve) => {
|
||||
const channelId = crypto.randomUUID();
|
||||
repacksWorker.postMessage([channelId, query]);
|
||||
|
||||
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
|
||||
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
|
||||
resolve(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const indexRepacks = useCallback(() => {
|
||||
setIsIndexingRepacks(true);
|
||||
repacksWorker.postMessage("INDEX_REPACKS");
|
||||
|
||||
repacksWorker.onmessage = () => {
|
||||
setIsIndexingRepacks(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
indexRepacks();
|
||||
}, [indexRepacks]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
searchRepacks,
|
||||
indexRepacks,
|
||||
isIndexingRepacks,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
46
src/renderer/src/cookies.ts
Normal file
46
src/renderer/src/cookies.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function addCookieInterceptor(isStaging: boolean) {
|
||||
const cookieKey = isStaging ? "cookies-staging" : "cookies";
|
||||
|
||||
Object.defineProperty(document, "cookie", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
return localStorage.getItem(cookieKey) || "";
|
||||
},
|
||||
set(cookieString) {
|
||||
try {
|
||||
const [cookieName, cookieValue] = cookieString.split(";")[0].split("=");
|
||||
|
||||
const currentCookies = localStorage.getItem(cookieKey) || "";
|
||||
|
||||
const cookiesObject = parseCookieStringsToObjects(currentCookies);
|
||||
cookiesObject[cookieName] = cookieValue;
|
||||
|
||||
const newString = Object.entries(cookiesObject)
|
||||
.map(([key, value]) => {
|
||||
return key + "=" + value;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
localStorage.setItem(cookieKey, newString);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const parseCookieStringsToObjects = (
|
||||
cookieStrings: string
|
||||
): { [key: string]: string } => {
|
||||
const result = {};
|
||||
|
||||
if (cookieStrings === "") return result;
|
||||
|
||||
cookieStrings.split(";").forEach((cookieString) => {
|
||||
const [name, value] = cookieString.split("=");
|
||||
result[name.trim()] = value.trim();
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
61
src/renderer/src/declaration.d.ts
vendored
61
src/renderer/src/declaration.d.ts
vendored
@@ -1,19 +1,17 @@
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type { AuthPage, CatalogueCategory } from "@shared";
|
||||
import type {
|
||||
AppUpdaterEvent,
|
||||
CatalogueEntry,
|
||||
Game,
|
||||
LibraryGame,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
Steam250Game,
|
||||
DownloadProgress,
|
||||
SeedingStatus,
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@@ -30,9 +28,10 @@ import type {
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
import type disk from "diskusage";
|
||||
|
||||
declare global {
|
||||
declare module "*.svg" {
|
||||
@@ -46,13 +45,23 @@ declare global {
|
||||
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>;
|
||||
onDownloadProgress: (
|
||||
cb: (value: DownloadProgress) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onSeedingStatus: (
|
||||
cb: (value: SeedingStatus[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||
searchGames: (
|
||||
payload: CatalogueSearchPayload,
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<{ edges: any[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -63,8 +72,6 @@ declare global {
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
onUpdateAchievements: (
|
||||
@@ -72,6 +79,8 @@ declare global {
|
||||
shop: GameShop,
|
||||
cb: (achievements: GameAchievement[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
getPublishers: () => Promise<string[]>;
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
@@ -80,14 +89,28 @@ declare global {
|
||||
shop: GameShop
|
||||
) => Promise<void>;
|
||||
createGameShortcut: (id: number) => Promise<boolean>;
|
||||
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
|
||||
selectGameWinePrefix: (id: number, winePrefixPath: string) => Promise<void>;
|
||||
updateExecutablePath: (
|
||||
id: number,
|
||||
executablePath: string | null
|
||||
) => Promise<void>;
|
||||
updateLaunchOptions: (
|
||||
id: number,
|
||||
launchOptions: string | null
|
||||
) => Promise<void>;
|
||||
selectGameWinePrefix: (
|
||||
id: number,
|
||||
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>;
|
||||
openGame: (gameId: number, executablePath: string) => Promise<void>;
|
||||
openGame: (
|
||||
gameId: number,
|
||||
executablePath: string,
|
||||
launchOptions: string | null
|
||||
) => Promise<void>;
|
||||
closeGame: (gameId: number) => Promise<boolean>;
|
||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||
removeGame: (gameId: number) => Promise<void>;
|
||||
@@ -99,7 +122,7 @@ declare global {
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
resetGameAchievements: (gameId: number) => Promise<void>;
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
@@ -112,11 +135,13 @@ declare global {
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
deleteDownloadSource: (id: number) => Promise<void>;
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
|
||||
checkFolderWritePermission: (path: string) => Promise<boolean>;
|
||||
|
||||
/* Cloud save */
|
||||
uploadSaveGame: (
|
||||
@@ -171,6 +196,7 @@ declare global {
|
||||
options: Electron.OpenDialogOptions
|
||||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
showItemInFolder: (path: string) => Promise<void>;
|
||||
getFeatures: () => Promise<string[]>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
@@ -182,9 +208,10 @@ declare global {
|
||||
|
||||
/* Auth */
|
||||
signOut: () => Promise<void>;
|
||||
openAuthWindow: () => Promise<void>;
|
||||
openAuthWindow: (page: AuthPage) => Promise<void>;
|
||||
getSessionHash: () => Promise<string | null>;
|
||||
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* User */
|
||||
|
||||
@@ -21,11 +21,10 @@ export interface CatalogueCache {
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(5).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
db.version(8).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
@@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
||||
"howLongToBeatEntries"
|
||||
);
|
||||
|
||||
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
|
||||
|
||||
db.open();
|
||||
|
||||
67
src/renderer/src/features/catalogue-search.ts
Normal file
67
src/renderer/src/features/catalogue-search.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { CatalogueSearchPayload } from "@types";
|
||||
|
||||
export interface CatalogueSearchState {
|
||||
filters: CatalogueSearchPayload;
|
||||
page: number;
|
||||
steamUserTags: Record<string, Record<string, number>>;
|
||||
steamGenres: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const initialState: CatalogueSearchState = {
|
||||
filters: {
|
||||
title: "",
|
||||
downloadSourceFingerprints: [],
|
||||
tags: [],
|
||||
publishers: [],
|
||||
genres: [],
|
||||
developers: [],
|
||||
},
|
||||
steamUserTags: {},
|
||||
steamGenres: {},
|
||||
page: 1,
|
||||
};
|
||||
|
||||
export const catalogueSearchSlice = createSlice({
|
||||
name: "catalogueSearch",
|
||||
initialState,
|
||||
reducers: {
|
||||
setFilters: (
|
||||
state,
|
||||
action: PayloadAction<Partial<CatalogueSearchPayload>>
|
||||
) => {
|
||||
state.filters = { ...state.filters, ...action.payload };
|
||||
state.page = initialState.page;
|
||||
},
|
||||
clearFilters: (state) => {
|
||||
state.filters = initialState.filters;
|
||||
state.page = initialState.page;
|
||||
},
|
||||
setPage: (state, action: PayloadAction<number>) => {
|
||||
state.page = action.payload;
|
||||
},
|
||||
clearPage: (state) => {
|
||||
state.page = initialState.page;
|
||||
},
|
||||
setTags: (
|
||||
state,
|
||||
action: PayloadAction<Record<string, Record<string, number>>>
|
||||
) => {
|
||||
state.steamUserTags = action.payload;
|
||||
},
|
||||
setGenres: (state, action: PayloadAction<Record<string, string[]>>) => {
|
||||
state.steamGenres = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setPage,
|
||||
clearPage,
|
||||
setTags,
|
||||
setGenres,
|
||||
} = catalogueSearchSlice.actions;
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./search-slice";
|
||||
export * from "./library-slice";
|
||||
export * from "./use-preferences-slice";
|
||||
export * from "./download-slice";
|
||||
@@ -6,3 +5,6 @@ export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./subscription-slice";
|
||||
export * from "./repacks-slice";
|
||||
export * from "./catalogue-search";
|
||||
|
||||
24
src/renderer/src/features/repacks-slice.ts
Normal file
24
src/renderer/src/features/repacks-slice.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
export interface RepacksState {
|
||||
value: GameRepack[];
|
||||
}
|
||||
|
||||
const initialState: RepacksState = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
export const repacksSlice = createSlice({
|
||||
name: "repacks",
|
||||
initialState,
|
||||
reducers: {
|
||||
setRepacks: (state, action: PayloadAction<RepacksState["value"]>) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setRepacks } = repacksSlice.actions;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface SearchState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const initialState: SearchState = {
|
||||
value: "",
|
||||
};
|
||||
|
||||
export const searchSlice = createSlice({
|
||||
name: "search",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
clearSearch: (state) => {
|
||||
state.value = "";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSearch, clearSearch } = searchSlice.actions;
|
||||
32
src/renderer/src/features/subscription-slice.ts
Normal file
32
src/renderer/src/features/subscription-slice.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { HydraCloudFeature } from "@types";
|
||||
|
||||
export interface SubscriptionState {
|
||||
isHydraCloudModalVisible: boolean;
|
||||
feature: HydraCloudFeature | "";
|
||||
}
|
||||
|
||||
const initialState: SubscriptionState = {
|
||||
isHydraCloudModalVisible: false,
|
||||
feature: "",
|
||||
};
|
||||
|
||||
export const subscriptionSlice = createSlice({
|
||||
name: "subscription",
|
||||
initialState,
|
||||
reducers: {
|
||||
setHydraCloudModalVisible: (
|
||||
state,
|
||||
action: PayloadAction<HydraCloudFeature>
|
||||
) => {
|
||||
state.isHydraCloudModalVisible = true;
|
||||
state.feature = action.payload;
|
||||
},
|
||||
setHydraCloudModalHidden: (state) => {
|
||||
state.isHydraCloudModalVisible = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setHydraCloudModalVisible, setHydraCloudModalHidden } =
|
||||
subscriptionSlice.actions;
|
||||
@@ -2,14 +2,17 @@ import type { GameShop } from "@types";
|
||||
|
||||
import Color from "color";
|
||||
|
||||
export const formatDownloadProgress = (progress?: number) => {
|
||||
export const formatDownloadProgress = (
|
||||
progress?: number,
|
||||
fractionDigits?: number
|
||||
) => {
|
||||
if (!progress) return "0%";
|
||||
const progressPercentage = progress * 100;
|
||||
|
||||
if (Number(progressPercentage.toFixed(2)) % 1 === 0)
|
||||
if (Number(progressPercentage.toFixed(fractionDigits ?? 2)) % 1 === 0)
|
||||
return `${Math.floor(progressPercentage)}%`;
|
||||
|
||||
return `${progressPercentage.toFixed(2)}%`;
|
||||
return `${progressPercentage.toFixed(fractionDigits ?? 2)}%`;
|
||||
};
|
||||
|
||||
export const getSteamLanguage = (language: string) => {
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-repacks";
|
||||
export * from "./use-feature";
|
||||
|
||||
53
src/renderer/src/hooks/use-catalogue.ts
Normal file
53
src/renderer/src/hooks/use-catalogue.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { setGenres, setTags } from "@renderer/features";
|
||||
|
||||
export const externalResourcesInstance = axios.create({
|
||||
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
|
||||
});
|
||||
|
||||
export function useCatalogue() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
|
||||
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
|
||||
|
||||
const getSteamUserTags = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
|
||||
dispatch(setTags(response.data));
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const getSteamGenres = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-genres.json").then((response) => {
|
||||
dispatch(setGenres(response.data));
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const getSteamPublishers = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-publishers.json").then((response) => {
|
||||
setSteamPublishers(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getSteamDevelopers = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-developers.json").then((response) => {
|
||||
setSteamDevelopers(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSteamUserTags();
|
||||
getSteamGenres();
|
||||
getSteamPublishers();
|
||||
getSteamDevelopers();
|
||||
}, [
|
||||
getSteamUserTags,
|
||||
getSteamGenres,
|
||||
getSteamPublishers,
|
||||
getSteamDevelopers,
|
||||
]);
|
||||
|
||||
return { steamPublishers, steamDevelopers };
|
||||
}
|
||||
@@ -66,6 +66,16 @@ export function useDownload() {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const pauseSeeding = async (gameId: number) => {
|
||||
await window.electron.pauseGameSeed(gameId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
const resumeSeeding = async (gameId: number) => {
|
||||
await window.electron.resumeGameSeed(gameId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
const calculateETA = () => {
|
||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||
|
||||
@@ -96,6 +106,8 @@ export function useDownload() {
|
||||
removeGameFromLibrary,
|
||||
removeGameInstaller,
|
||||
isGameDeleting,
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
clearDownload: () => dispatch(clearDownload()),
|
||||
setLastPacket: (packet: DownloadProgress) =>
|
||||
dispatch(setLastPacket(packet)),
|
||||
|
||||
23
src/renderer/src/hooks/use-feature.ts
Normal file
23
src/renderer/src/hooks/use-feature.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
enum Feature {
|
||||
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
|
||||
}
|
||||
|
||||
export function useFeature() {
|
||||
useEffect(() => {
|
||||
window.electron.getFeatures().then((features) => {
|
||||
localStorage.setItem("features", JSON.stringify(features || []));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFeatureEnabled = (feature: Feature) => {
|
||||
const features = JSON.parse(localStorage.getItem("features") || "[]");
|
||||
return features.includes(feature);
|
||||
};
|
||||
|
||||
return {
|
||||
isFeatureEnabled,
|
||||
Feature,
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export function useFormat() {
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
return { numberFormatter };
|
||||
return { numberFormatter, formatNumber: numberFormatter.format };
|
||||
}
|
||||
|
||||
34
src/renderer/src/hooks/use-repacks.ts
Normal file
34
src/renderer/src/hooks/use-repacks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { setRepacks } from "@renderer/features";
|
||||
import { useCallback } from "react";
|
||||
import { RootState } from "@renderer/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useAppDispatch } from "./redux";
|
||||
|
||||
export function useRepacks() {
|
||||
const dispatch = useAppDispatch();
|
||||
const repacks = useSelector((state: RootState) => state.repacks.value);
|
||||
|
||||
const getRepacksForObjectId = useCallback(
|
||||
(objectId: string) => {
|
||||
return repacks.filter((repack) => repack.objectIds.includes(objectId));
|
||||
},
|
||||
[repacks]
|
||||
);
|
||||
|
||||
const updateRepacks = useCallback(() => {
|
||||
repacksTable.toArray().then((repacks) => {
|
||||
dispatch(
|
||||
setRepacks(
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
repacks.filter((repack) => Array.isArray(repack.objectIds))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return { getRepacksForObjectId, updateRepacks };
|
||||
}
|
||||
33
src/renderer/src/hooks/use-subscription.ts
Normal file
33
src/renderer/src/hooks/use-subscription.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import {
|
||||
setHydraCloudModalVisible,
|
||||
setHydraCloudModalHidden,
|
||||
} from "@renderer/features";
|
||||
import { HydraCloudFeature } from "@types";
|
||||
|
||||
export function useSubscription() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isHydraCloudModalVisible, feature } = useAppSelector(
|
||||
(state) => state.subscription
|
||||
);
|
||||
|
||||
const showHydraCloudModal = useCallback(
|
||||
(feature: HydraCloudFeature) => {
|
||||
dispatch(setHydraCloudModalVisible(feature));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const hideHydraCloudModal = useCallback(() => {
|
||||
dispatch(setHydraCloudModalHidden());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
isHydraCloudModalVisible,
|
||||
hydraCloudFeature: feature,
|
||||
showHydraCloudModal,
|
||||
hideHydraCloudModal,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
UpdateProfileRequest,
|
||||
UserDetails,
|
||||
} from "@types";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import { isFuture, isToday } from "date-fns";
|
||||
|
||||
@@ -30,6 +31,8 @@ export function useUserDetails() {
|
||||
} = useAppSelector((state) => state.userDetails);
|
||||
|
||||
const clearUserDetails = useCallback(async () => {
|
||||
Sentry.setUser(null);
|
||||
|
||||
dispatch(setUserDetails(null));
|
||||
dispatch(setProfileBackground(null));
|
||||
|
||||
@@ -44,6 +47,12 @@ export function useUserDetails() {
|
||||
|
||||
const updateUserDetails = useCallback(
|
||||
async (userDetails: UserDetails) => {
|
||||
Sentry.setUser({
|
||||
id: userDetails.id,
|
||||
username: userDetails.username,
|
||||
email: userDetails.email ?? undefined,
|
||||
});
|
||||
|
||||
dispatch(setUserDetails(userDetails));
|
||||
window.localStorage.setItem("userDetails", JSON.stringify(userDetails));
|
||||
},
|
||||
|
||||
@@ -18,16 +18,15 @@ import { store } from "./store";
|
||||
|
||||
import resources from "@locales";
|
||||
|
||||
import { RepacksContextProvider } from "./context";
|
||||
import { SuspenseWrapper } from "./components";
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
|
||||
const Home = React.lazy(() => import("./pages/home/home"));
|
||||
const GameDetails = React.lazy(
|
||||
() => import("./pages/game-details/game-details")
|
||||
);
|
||||
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
|
||||
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
|
||||
const Settings = React.lazy(() => import("./pages/settings/settings"));
|
||||
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
||||
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
||||
@@ -35,8 +34,24 @@ const Achievements = React.lazy(
|
||||
() => import("./pages/achievements/achievements")
|
||||
);
|
||||
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration(),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
|
||||
console.log = logger.log;
|
||||
|
||||
const isStaging = await window.electron.isStaging();
|
||||
addCookieInterceptor(isStaging);
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
@@ -60,43 +75,37 @@ i18n
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
|
||||
<Route
|
||||
path="/catalogue"
|
||||
element={<SuspenseWrapper Component={Catalogue} />}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={<SuspenseWrapper Component={Downloads} />}
|
||||
/>
|
||||
<Route
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={<SuspenseWrapper Component={SearchResults} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={<SuspenseWrapper Component={Profile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/achievements"
|
||||
element={<SuspenseWrapper Component={Achievements} />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
|
||||
<Route
|
||||
path="/catalogue"
|
||||
element={<SuspenseWrapper Component={Catalogue} />}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={<SuspenseWrapper Component={Downloads} />}
|
||||
/>
|
||||
<Route
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={<SuspenseWrapper Component={Profile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/achievements"
|
||||
element={<SuspenseWrapper Component={Achievements} />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
94
src/renderer/src/pages/achievements/achievement-list.tsx
Normal file
94
src/renderer/src/pages/achievements/achievement-list.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import type { UserAchievement } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievements.css";
|
||||
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) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{achievements.map((achievement) => (
|
||||
<li
|
||||
key={achievement.name}
|
||||
className={styles.listItem}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{achievement.hidden && (
|
||||
<span
|
||||
style={{ display: "flex" }}
|
||||
title={t("hidden_achievement_tooltip")}
|
||||
>
|
||||
<EyeClosedIcon size={12} />
|
||||
</span>
|
||||
)}
|
||||
{achievement.displayName}
|
||||
</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{achievement.points != undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px" }}
|
||||
title={t("achievement_earn_points", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>{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: "???",
|
||||
})}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>???</p>
|
||||
</button>
|
||||
)}
|
||||
{achievement.unlockTime != null && (
|
||||
<div
|
||||
title={t("unlocked_at", {
|
||||
date: formatDateTime(achievement.unlockTime),
|
||||
})}
|
||||
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
|
||||
>
|
||||
<small>{formatDateTime(achievement.unlockTime)}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
71
src/renderer/src/pages/achievements/achievement-panel.css.ts
Normal file
71
src/renderer/src/pages/achievements/achievement-panel.css.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const panel = style({
|
||||
width: "100%",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "start",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const link = style({
|
||||
textAlign: "start",
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
57
src/renderer/src/pages/achievements/achievement-panel.tsx
Normal file
57
src/renderer/src/pages/achievements/achievement-panel.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { UserAchievement } from "@types";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import * as styles from "./achievement-panel.css";
|
||||
|
||||
export interface AchievementPanelProps {
|
||||
achievements: UserAchievement[];
|
||||
}
|
||||
|
||||
export function AchievementPanel({ achievements }: AchievementPanelProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const achievementsPointsTotal = achievements.reduce(
|
||||
(acc, achievement) => acc + (achievement.points ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
const achievementsPointsEarnedSum = achievements.reduce(
|
||||
(acc, achievement) =>
|
||||
acc + (achievement.unlocked ? (achievement.points ?? 0) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.content}>
|
||||
{t("earned_points")} <HydraIcon width={20} height={20} />
|
||||
??? / ???
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements-points")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("how_to_earn_achievements_points")}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.content}>
|
||||
{t("earned_points")} <HydraIcon width={20} height={20} />
|
||||
{achievementsPointsEarnedSum} / {achievementsPointsTotal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
|
||||
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievements.css";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
@@ -11,11 +10,16 @@ import {
|
||||
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import type { ComparedAchievements, UserAchievement } from "@types";
|
||||
import type { ComparedAchievements } from "@types";
|
||||
import { average } from "color.js";
|
||||
import Color from "color";
|
||||
import { Link } from "@renderer/components";
|
||||
import { ComparedAchievementList } from "./compared-achievement-list";
|
||||
import * as styles from "./achievements.css";
|
||||
import { AchievementList } from "./achievement-list";
|
||||
import { AchievementPanel } from "./achievement-panel";
|
||||
import { ComparedAchievementPanel } from "./compared-achievement-panel";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
@@ -30,10 +34,6 @@ interface AchievementsContentProps {
|
||||
comparedAchievements: ComparedAchievements | null;
|
||||
}
|
||||
|
||||
interface AchievementListProps {
|
||||
achievements: UserAchievement[];
|
||||
}
|
||||
|
||||
interface AchievementSummaryProps {
|
||||
user: UserInfo;
|
||||
isComparison?: boolean;
|
||||
@@ -42,7 +42,7 @@ interface AchievementSummaryProps {
|
||||
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { handleClickOpenCheckout } = useContext(gameDetailsContext);
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const getProfileImage = (
|
||||
user: Pick<UserInfo, "profileImageUrl" | "displayName">
|
||||
@@ -93,7 +93,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
<h3>
|
||||
<button
|
||||
className={styles.subscriptionRequiredButton}
|
||||
onClick={handleClickOpenCheckout}
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
>
|
||||
{t("subscription_needed")}
|
||||
</button>
|
||||
@@ -171,38 +171,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementList({ achievements }: AchievementListProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{achievements.map((achievement, index) => (
|
||||
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4>{achievement.displayName}</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
{achievement.unlockTime && (
|
||||
<div style={{ whiteSpace: "nowrap" }}>
|
||||
<small>{t("unlocked_at")}</small>
|
||||
<p>{formatDateTime(achievement.unlockTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function AchievementsContent({
|
||||
otherUser,
|
||||
comparedAchievements,
|
||||
@@ -355,9 +323,15 @@ export function AchievementsContent({
|
||||
)}
|
||||
|
||||
{otherUser ? (
|
||||
<ComparedAchievementList achievements={comparedAchievements!} />
|
||||
<>
|
||||
<ComparedAchievementPanel achievements={comparedAchievements!} />
|
||||
<ComparedAchievementList achievements={comparedAchievements!} />
|
||||
</>
|
||||
) : (
|
||||
<AchievementList achievements={achievements!} />
|
||||
<>
|
||||
<AchievementPanel achievements={achievements!} />
|
||||
<AchievementList achievements={achievements!} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { ComparedAchievements } from "@types";
|
||||
import * as styles from "./achievements.css";
|
||||
import { CheckCircleIcon, LockIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
EyeClosedIcon,
|
||||
LockIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ComparedAchievementListProps {
|
||||
achievements: ComparedAchievements;
|
||||
@@ -11,6 +16,7 @@ export interface ComparedAchievementListProps {
|
||||
export function ComparedAchievementList({
|
||||
achievements,
|
||||
}: ComparedAchievementListProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
@@ -43,7 +49,17 @@ export function ComparedAchievementList({
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<h4>{achievement.displayName}</h4>
|
||||
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{achievement.hidden && (
|
||||
<span
|
||||
style={{ display: "flex" }}
|
||||
title={t("hidden_achievement_tooltip")}
|
||||
>
|
||||
<EyeClosedIcon size={12} />
|
||||
</span>
|
||||
)}
|
||||
{achievement.displayName}
|
||||
</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,11 +74,9 @@ export function ComparedAchievementList({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
title={formatDateTime(achievement.ownerStat.unlockTime!)}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<small>
|
||||
{formatDateTime(achievement.ownerStat.unlockTime!)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -86,11 +100,9 @@ export function ComparedAchievementList({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
title={formatDateTime(achievement.targetStat.unlockTime!)}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<small>
|
||||
{formatDateTime(achievement.targetStat.unlockTime!)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievement-panel.css";
|
||||
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { ComparedAchievements } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
|
||||
export interface ComparedAchievementPanelProps {
|
||||
achievements: ComparedAchievements;
|
||||
}
|
||||
|
||||
export function ComparedAchievementPanel({
|
||||
achievements,
|
||||
}: ComparedAchievementPanelProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: hasActiveSubscription ? "3fr 1fr 1fr" : "3fr 2fr",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{t("available_points")} <HydraIcon width={20} height={20} />{" "}
|
||||
{achievements.achievementsPointsTotal}
|
||||
</div>
|
||||
{hasActiveSubscription && (
|
||||
<div className={styles.content}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{achievements.owner.achievementsPointsEarnedSum ?? 0}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{achievements.target.achievementsPointsEarnedSum}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
22
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.catalogue {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&__filters-container {
|
||||
width: 270px;
|
||||
min-width: 270px;
|
||||
max-width: 270px;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
border: 1px solid globals.$border-color;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,373 @@
|
||||
import { Button, GameCard } from "@renderer/components";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DownloadSource } from "@types";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useFormat,
|
||||
useRepacks,
|
||||
} from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as styles from "../home/home.css";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import "./catalogue.scss";
|
||||
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { FilterSection } from "./filter-section";
|
||||
import { setFilters, setPage } from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { Pagination } from "./pagination";
|
||||
import { useCatalogue } from "@renderer/hooks/use-catalogue";
|
||||
import { GameItem } from "./game-item";
|
||||
import { FilterItem } from "./filter-item";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
const filterCategoryColors = {
|
||||
genres: "hsl(262deg 50% 47%)",
|
||||
tags: "hsl(95deg 50% 20%)",
|
||||
downloadSourceFingerprints: "hsl(27deg 50% 40%)",
|
||||
developers: "hsl(340deg 50% 46%)",
|
||||
publishers: "hsl(200deg 50% 30%)",
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function Catalogue() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cataloguePageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { steamDevelopers, steamPublishers } = useCatalogue();
|
||||
|
||||
const { steamGenres, steamUserTags } = useAppSelector(
|
||||
(state) => state.catalogueSearch
|
||||
);
|
||||
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
|
||||
const [itemsCount, setItemsCount] = useState(0);
|
||||
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("catalogue");
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const debouncedSearch = useRef(
|
||||
debounce(async (filters, pageSize, offset) => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const response = await window.electron.searchGames(
|
||||
filters,
|
||||
pageSize,
|
||||
offset
|
||||
);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const skip = Number(searchParams.get("skip") ?? 0);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
setResults(response.edges);
|
||||
setItemsCount(response.count);
|
||||
setIsLoading(false);
|
||||
}, 500)
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
setResults([]);
|
||||
setIsLoading(true);
|
||||
setSearchResults([]);
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
window.electron
|
||||
.getGames(24, skip)
|
||||
.then((results) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
setSearchResults(results);
|
||||
resolve(null);
|
||||
}, 500);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [dispatch, skip, searchParams]);
|
||||
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
|
||||
|
||||
const handleNextPage = () => {
|
||||
const params = new URLSearchParams({
|
||||
skip: String(skip + 24),
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [filters, page, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
setDownloadSources(sources.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, [getRepacksForObjectId]);
|
||||
|
||||
navigate(`/catalogue?${params.toString()}`);
|
||||
};
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
const steamGenresMapping = useMemo<Record<string, string>>(() => {
|
||||
if (!steamGenres[language]) return {};
|
||||
|
||||
return steamGenres[language].reduce((prev, genre, index) => {
|
||||
prev[genre] = steamGenres["en"][index];
|
||||
return prev;
|
||||
}, {});
|
||||
}, [steamGenres, language]);
|
||||
|
||||
const steamGenresFilterItems = useMemo(() => {
|
||||
return Object.entries(steamGenresMapping)
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
checked: filters.genres.includes(value),
|
||||
}));
|
||||
}, [steamGenresMapping, filters.genres]);
|
||||
|
||||
const steamUserTagsFilterItems = useMemo(() => {
|
||||
if (!steamUserTags[language]) return [];
|
||||
|
||||
return Object.entries(steamUserTags[language])
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
checked: filters.tags.includes(value),
|
||||
}));
|
||||
}, [steamUserTags, filters.tags, language]);
|
||||
|
||||
const groupedFilters = useMemo(() => {
|
||||
return [
|
||||
...filters.genres.map((genre) => ({
|
||||
label: Object.keys(steamGenresMapping).find(
|
||||
(key) => steamGenresMapping[key] === genre
|
||||
) as string,
|
||||
orbColor: filterCategoryColors.genres,
|
||||
key: "genres",
|
||||
value: genre,
|
||||
})),
|
||||
|
||||
...filters.tags.map((tag) => ({
|
||||
label: Object.keys(steamUserTags[language]).find(
|
||||
(key) => steamUserTags[language][key] === tag
|
||||
),
|
||||
orbColor: filterCategoryColors.tags,
|
||||
key: "tags",
|
||||
value: tag,
|
||||
})),
|
||||
|
||||
...filters.downloadSourceFingerprints.map((fingerprint) => ({
|
||||
label: downloadSources.find(
|
||||
(source) => source.fingerprint === fingerprint
|
||||
)?.name as string,
|
||||
orbColor: filterCategoryColors.downloadSourceFingerprints,
|
||||
key: "downloadSourceFingerprints",
|
||||
value: fingerprint,
|
||||
})),
|
||||
|
||||
...filters.developers.map((developer) => ({
|
||||
label: developer,
|
||||
orbColor: filterCategoryColors.developers,
|
||||
key: "developers",
|
||||
value: developer,
|
||||
})),
|
||||
|
||||
...filters.publishers.map((publisher) => ({
|
||||
label: publisher,
|
||||
orbColor: filterCategoryColors.publishers,
|
||||
key: "publishers",
|
||||
value: publisher,
|
||||
})),
|
||||
];
|
||||
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
|
||||
|
||||
const filterSections = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: t("genres"),
|
||||
items: steamGenresFilterItems,
|
||||
key: "genres",
|
||||
},
|
||||
{
|
||||
title: t("tags"),
|
||||
items: steamUserTagsFilterItems,
|
||||
key: "tags",
|
||||
},
|
||||
{
|
||||
title: t("download_sources"),
|
||||
items: downloadSources.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint
|
||||
),
|
||||
})),
|
||||
key: "downloadSourceFingerprints",
|
||||
},
|
||||
{
|
||||
title: t("developers"),
|
||||
items: steamDevelopers.map((developer) => ({
|
||||
label: developer,
|
||||
value: developer,
|
||||
checked: filters.developers.includes(developer),
|
||||
})),
|
||||
key: "developers",
|
||||
},
|
||||
{
|
||||
title: t("publishers"),
|
||||
items: steamPublishers.map((publisher) => ({
|
||||
label: publisher,
|
||||
value: publisher,
|
||||
checked: filters.publishers.includes(publisher),
|
||||
})),
|
||||
key: "publishers",
|
||||
},
|
||||
];
|
||||
}, [
|
||||
downloadSources,
|
||||
filters.developers,
|
||||
filters.downloadSourceFingerprints,
|
||||
filters.publishers,
|
||||
steamDevelopers,
|
||||
steamGenresFilterItems,
|
||||
steamPublishers,
|
||||
steamUserTagsFilterItems,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section
|
||||
<div className="catalogue" ref={cataloguePageRef}>
|
||||
<div
|
||||
style={{
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${vars.color.border}`,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
theme="outline"
|
||||
disabled={skip === 0 || isLoading}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("previous_page")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
|
||||
{t("next_page")}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<section className={styles.cards}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<ul
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{groupedFilters.map((filter) => (
|
||||
<li key={`${filter.key}-${filter.value}`}>
|
||||
<FilterItem
|
||||
filter={filter.label ?? ""}
|
||||
orbColor={filter.orbColor}
|
||||
onRemove={() => {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[filter.key]: filters[filter.key].filter(
|
||||
(item) => item !== filter.value
|
||||
),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: SPACING_UNIT * 2,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.darkBackground}
|
||||
highlightColor={vars.color.background}
|
||||
>
|
||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
style={{
|
||||
height: 105,
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</SkeletonTheme>
|
||||
) : (
|
||||
results.map((game) => <GameItem key={game.id} game={game} />)
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{t("result_count", {
|
||||
resultCount: formatNumber(itemsCount),
|
||||
})}
|
||||
</span>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
|
||||
onPageChange={(page) => {
|
||||
dispatch(setPage(page));
|
||||
if (cataloguePageRef.current) {
|
||||
cataloguePageRef.current.scrollTop = 0;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="catalogue__filters-container">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{filterSections.map((section) => (
|
||||
<FilterSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
onClear={() => dispatch(setFilters({ [section.key]: [] }))}
|
||||
color={filterCategoryColors[section.key]}
|
||||
onSelect={(value) => {
|
||||
if (filters[section.key].includes(value)) {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[section.key]: filters[
|
||||
section.key as
|
||||
| "genres"
|
||||
| "tags"
|
||||
| "downloadSourceFingerprints"
|
||||
| "developers"
|
||||
| "publishers"
|
||||
].filter((item) => item !== value),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[section.key]: [...filters[section.key], value],
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={section.items}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/renderer/src/pages/catalogue/filter-item.tsx
Normal file
50
src/renderer/src/pages/catalogue/filter-item.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
|
||||
interface FilterItemProps {
|
||||
filter: string;
|
||||
orbColor: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: vars.color.body,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: orbColor,
|
||||
borderRadius: "50%",
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
color: vars.color.body,
|
||||
marginLeft: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
136
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { CheckboxField, TextField } from "@renderer/components";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import List from "rc-virtual-list";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface FilterSectionProps {
|
||||
title: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
checked: boolean;
|
||||
}[];
|
||||
onSelect: (value: string | number) => void;
|
||||
color: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function FilterSection({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: FilterSectionProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const { t } = useTranslation("catalogue");
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (search.length > 0) {
|
||||
return items.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [items, search]);
|
||||
|
||||
const selectedItemsCount = useMemo(() => {
|
||||
return items.filter((item) => item.checked).length;
|
||||
}, [items]);
|
||||
|
||||
const onSearch = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
}, []);
|
||||
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: color,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{selectedItemsCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
display: "block",
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onClick={onClear}
|
||||
>
|
||||
{t("clear_filters", {
|
||||
filterCount: formatNumber(selectedItemsCount),
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, marginBottom: 12, display: "block" }}>
|
||||
{t("filter_count", {
|
||||
filterCount: formatNumber(items.length),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
value={search}
|
||||
containerProps={{ style: { marginBottom: 16 } }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
<List
|
||||
data={filteredItems}
|
||||
height={28 * (filteredItems.length > 10 ? 10 : filteredItems.length)}
|
||||
itemHeight={28}
|
||||
itemKey="value"
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.03)",
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "24px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div key={item.value} style={{ height: 28, maxHeight: 28 }}>
|
||||
<CheckboxField
|
||||
label={item.label}
|
||||
checked={item.checked}
|
||||
onChange={() => onSelect(item.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/renderer/src/pages/catalogue/game-item.scss
Normal file
48
src/renderer/src/pages/catalogue/game-item.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.game-item {
|
||||
background-color: globals.$dark-background-color;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
border: 1px solid globals.$border-color;
|
||||
cursor: pointer;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-right: 1px solid globals.$border-color;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: calc(globals.$spacing-unit * 2) 0;
|
||||
}
|
||||
|
||||
&__genres {
|
||||
color: globals.$body-color;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__repackers {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
71
src/renderer/src/pages/catalogue/game-item.tsx
Normal file
71
src/renderer/src/pages/catalogue/game-item.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Badge } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useAppSelector, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import "./game-item.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface GameItemProps {
|
||||
game: any;
|
||||
}
|
||||
|
||||
export function GameItem({ game }: GameItemProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
const uniqueRepackers = useMemo(() => {
|
||||
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
|
||||
}, [repacks]);
|
||||
|
||||
const genres = useMemo(() => {
|
||||
return game.genres?.map((genre) => {
|
||||
const index = steamGenres["en"]?.findIndex(
|
||||
(steamGenre) => steamGenre === genre
|
||||
);
|
||||
|
||||
if (index && steamGenres[language] && steamGenres[language][index]) {
|
||||
return steamGenres[language][index];
|
||||
}
|
||||
|
||||
return genre;
|
||||
});
|
||||
}, [game.genres, language, steamGenres]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="game-item"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
<img
|
||||
className="game-item__cover"
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="game-item__details">
|
||||
<span>{game.title}</span>
|
||||
<span className="game-item__genres">{genres.join(", ")}</span>
|
||||
|
||||
<div className="game-item__repackers">
|
||||
{uniqueRepackers.map((repacker) => (
|
||||
<Badge key={repacker}>{repacker}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
128
src/renderer/src/pages/catalogue/pagination.tsx
Normal file
128
src/renderer/src/pages/catalogue/pagination.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { useFormat } from "@renderer/hooks/use-format";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// Number of visible pages
|
||||
const visiblePages = 3;
|
||||
|
||||
// Calculate the start and end of the visible range
|
||||
let startPage = Math.max(1, page - 1); // Shift range slightly back
|
||||
let endPage = startPage + visiblePages - 1;
|
||||
|
||||
// Adjust the range if we're near the start or end
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - visiblePages + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
|
||||
{page > 2 && (
|
||||
<>
|
||||
{/* initial page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === 1}
|
||||
>
|
||||
{1}
|
||||
</Button>
|
||||
|
||||
{/* ellipsis */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page Buttons */}
|
||||
{Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, i) => startPage + i
|
||||
).map((pageNumber) => (
|
||||
<Button
|
||||
theme={page === pageNumber ? "primary" : "outline"}
|
||||
key={pageNumber}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{formatNumber(pageNumber)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{page < totalPages - 1 && (
|
||||
<>
|
||||
{/* ellipsis */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>...</span>
|
||||
</div>
|
||||
|
||||
{/* last page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
{formatNumber(totalPages)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import {
|
||||
@@ -15,12 +15,28 @@ import { useAppSelector, useDownload } from "@renderer/hooks";
|
||||
import * as styles from "./download-group.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
} from "@renderer/components/dropdown-menu/dropdown-menu";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
PlayIcon,
|
||||
ThreeBarsIcon,
|
||||
TrashIcon,
|
||||
UnlinkIcon,
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
openDeleteGameModal: (gameId: number) => void;
|
||||
openGameInstaller: (gameId: number) => void;
|
||||
seedingStatus: SeedingStatus[];
|
||||
}
|
||||
|
||||
export function DownloadGroup({
|
||||
@@ -28,6 +44,7 @@ export function DownloadGroup({
|
||||
title,
|
||||
openDeleteGameModal,
|
||||
openGameInstaller,
|
||||
seedingStatus,
|
||||
}: DownloadGroupProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -44,6 +61,8 @@ export function DownloadGroup({
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
isGameDeleting,
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
} = useDownload();
|
||||
|
||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||
@@ -57,9 +76,20 @@ export function DownloadGroup({
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
const seedingMap = useMemo(() => {
|
||||
const map = new Map<number, SeedingStatus>();
|
||||
|
||||
seedingStatus.forEach((seed) => {
|
||||
map.set(seed.gameId, seed);
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [seedingStatus]);
|
||||
|
||||
const getGameInfo = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const seedingStatus = seedingMap.get(game.id);
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
@@ -98,7 +128,17 @@ export function DownloadGroup({
|
||||
}
|
||||
|
||||
if (game.progress === 1) {
|
||||
return <p>{t("completed")}</p>;
|
||||
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
|
||||
|
||||
return game.status === "seeding" &&
|
||||
game.downloader === Downloader.Torrent ? (
|
||||
<>
|
||||
<p>{t("seeding")}</p>
|
||||
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("completed")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
@@ -125,59 +165,74 @@ export function DownloadGroup({
|
||||
return <p>{t(game.status as string)}</p>;
|
||||
};
|
||||
|
||||
const getGameActions = (game: LibraryGame) => {
|
||||
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openGameInstaller(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("install"),
|
||||
disabled: deleting,
|
||||
onClick: () => openGameInstaller(game.id),
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
label: t("stop_seeding"),
|
||||
disabled: deleting,
|
||||
icon: <UnlinkIcon />,
|
||||
show:
|
||||
game.status === "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => pauseSeeding(game.id),
|
||||
},
|
||||
{
|
||||
label: t("resume_seeding"),
|
||||
disabled: deleting,
|
||||
icon: <LinkIcon />,
|
||||
show:
|
||||
game.status !== "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => resumeSeeding(game.id),
|
||||
},
|
||||
{
|
||||
label: t("delete"),
|
||||
disabled: deleting,
|
||||
icon: <TrashIcon />,
|
||||
onClick: () => openDeleteGameModal(game.id),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("pause"),
|
||||
onClick: () => pauseDownload(game.id),
|
||||
icon: <ColumnsIcon />,
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => resumeDownload(game.id)}
|
||||
theme="outline"
|
||||
disabled={
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("resume"),
|
||||
disabled:
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken,
|
||||
onClick: () => resumeDownload(game.id),
|
||||
icon: <PlayIcon />,
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (!library.length) return null;
|
||||
@@ -207,7 +262,11 @@ export function DownloadGroup({
|
||||
<ul className={styles.downloads}>
|
||||
{library.map((game) => {
|
||||
return (
|
||||
<li key={game.id} className={styles.download}>
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.download}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<div className={styles.downloadCover}>
|
||||
<div className={styles.downloadCoverBackdrop}>
|
||||
<img
|
||||
@@ -243,9 +302,28 @@ export function DownloadGroup({
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadActions}>
|
||||
{getGameActions(game)}
|
||||
</div>
|
||||
{getGameActions(game) !== null && (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
items={getGameActions(game)}
|
||||
sideOffset={-75}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "12px",
|
||||
right: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
padding: "8px",
|
||||
minHeight: "unset",
|
||||
}}
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import type { LibraryGame } from "@types";
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
@@ -21,15 +21,23 @@ export default function Downloads() {
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const { removeGameInstaller } = useDownload();
|
||||
const { removeGameInstaller, pauseSeeding } = useDownload();
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
if (gameToBeDeleted.current)
|
||||
if (gameToBeDeleted.current) {
|
||||
await pauseSeeding(gameToBeDeleted.current);
|
||||
await removeGameInstaller(gameToBeDeleted.current);
|
||||
}
|
||||
};
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const [seedingStatus, setSeedingStatus] = useState<SeedingStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
}, []);
|
||||
|
||||
const handleOpenGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
@@ -119,9 +127,10 @@ export default function Downloads() {
|
||||
<DownloadGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
library={group.library}
|
||||
library={orderBy(group.library, ["updatedAt"], ["desc"])}
|
||||
openDeleteGameModal={handleOpenDeleteGameModal}
|
||||
openGameInstaller={handleOpenGameInstaller}
|
||||
seedingStatus={seedingStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosProgressEvent } from "axios";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
@@ -88,6 +88,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
}
|
||||
}, [getGameBackupPreview, visible]);
|
||||
|
||||
const userDetails = useAppSelector((state) => state.userDetails.userDetails);
|
||||
const backupsPerGameLimit = userDetails?.quirks?.backupsPerGameLimit ?? 0;
|
||||
|
||||
const backupStateLabel = useMemo(() => {
|
||||
if (uploadingBackup) {
|
||||
return (
|
||||
@@ -120,7 +123,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (artifacts.length >= 2) {
|
||||
if (artifacts.length >= backupsPerGameLimit) {
|
||||
return t("max_number_of_artifacts_reached");
|
||||
}
|
||||
|
||||
@@ -140,6 +143,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
restoringBackup,
|
||||
loadingPreview,
|
||||
artifacts,
|
||||
backupsPerGameLimit,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -181,7 +185,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
disabled={
|
||||
disableActions ||
|
||||
!backupPreview?.overall.totalGames ||
|
||||
artifacts.length >= 2
|
||||
artifacts.length >= backupsPerGameLimit
|
||||
}
|
||||
>
|
||||
<UploadIcon />
|
||||
@@ -199,7 +203,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
}}
|
||||
>
|
||||
<h2>{t("backups")}</h2>
|
||||
<small>{artifacts.length} / 2</small>
|
||||
<small>
|
||||
{artifacts.length} / {backupsPerGameLimit}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import { Sidebar } from "./sidebar/sidebar";
|
||||
import * as styles from "./game-details.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { AuthPage, steamUrlBuilder } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
|
||||
const HERO_ANIMATION_THRESHOLD = 25;
|
||||
|
||||
@@ -31,9 +32,10 @@ export function GameDetailsContent() {
|
||||
gameColor,
|
||||
setGameColor,
|
||||
hasNSFWContentBlocked,
|
||||
handleClickOpenCheckout,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
@@ -67,7 +69,7 @@ export function GameDetailsContent() {
|
||||
});
|
||||
|
||||
const backgroundColor = output
|
||||
? (new Color(output).darken(0.7).toString() as string)
|
||||
? new Color(output).darken(0.7).toString()
|
||||
: "";
|
||||
|
||||
setGameColor(backgroundColor);
|
||||
@@ -99,12 +101,12 @@ export function GameDetailsContent() {
|
||||
|
||||
const handleCloudSaveButtonClick = () => {
|
||||
if (!userDetails) {
|
||||
window.electron.openAuthWindow();
|
||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
handleClickOpenCheckout();
|
||||
showHydraCloudModal("backup");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,13 +55,21 @@ export function HeroPanelActions() {
|
||||
const openGame = async () => {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
game.executablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const gameExecutablePath = await selectGameExecutable();
|
||||
if (gameExecutablePath)
|
||||
window.electron.openGame(game.id, gameExecutablePath);
|
||||
window.electron.openGame(
|
||||
game.id,
|
||||
gameExecutablePath,
|
||||
game.launchOptions
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,3 +36,10 @@ export const downloaderIcon = style({
|
||||
position: "absolute",
|
||||
left: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const pathError = style({
|
||||
cursor: "pointer",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DiskSpace } from "check-disk-space";
|
||||
import * as styles from "./download-settings-modal.css";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
|
||||
|
||||
export interface DownloadSettingsModalProps {
|
||||
visible: boolean;
|
||||
@@ -33,21 +32,45 @@ export function DownloadSettingsModal({
|
||||
|
||||
const { showErrorToast } = useToast();
|
||||
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<number | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [selectedDownloader, setSelectedDownloader] =
|
||||
useState<Downloader | null>(null);
|
||||
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const getDiskFreeSpace = (path: string) => {
|
||||
window.electron.getDiskFreeSpace(path).then((result) => {
|
||||
setDiskFreeSpace(result.free);
|
||||
});
|
||||
};
|
||||
|
||||
const checkFolderWritePermission = useCallback(
|
||||
async (path: string) => {
|
||||
if (isFeatureEnabled(Feature.CheckDownloadWritePermission)) {
|
||||
const result = await window.electron.checkFolderWritePermission(path);
|
||||
setHasWritePermission(result);
|
||||
} else {
|
||||
setHasWritePermission(true);
|
||||
}
|
||||
},
|
||||
[Feature, isFeatureEnabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
getDiskFreeSpace(selectedPath);
|
||||
checkFolderWritePermission(selectedPath);
|
||||
}
|
||||
}, [visible, selectedPath]);
|
||||
}, [visible, checkFolderWritePermission, selectedPath]);
|
||||
|
||||
const downloaders = useMemo(() => {
|
||||
return getDownloadersForUris(repack?.uris ?? []);
|
||||
@@ -84,12 +107,6 @@ export function DownloadSettingsModal({
|
||||
userPreferences?.realDebridApiToken,
|
||||
]);
|
||||
|
||||
const getDiskFreeSpace = (path: string) => {
|
||||
window.electron.getDiskFreeSpace(path).then((result) => {
|
||||
setDiskFreeSpace(result);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: selectedPath,
|
||||
@@ -124,7 +141,7 @@ export function DownloadSettingsModal({
|
||||
visible={visible}
|
||||
title={t("download_settings")}
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
space: formatBytes(diskFreeSpace ?? 0),
|
||||
})}
|
||||
onClose={onClose}
|
||||
>
|
||||
@@ -159,16 +176,6 @@ export function DownloadSettingsModal({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDownloader != null &&
|
||||
selectedDownloader !== Downloader.Torrent && (
|
||||
<p style={{ marginTop: `${SPACING_UNIT}px` }}>
|
||||
<span style={{ color: vars.color.warning }}>
|
||||
{t("warning")}
|
||||
</span>{" "}
|
||||
{t("hydra_needs_to_remain_open")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -178,23 +185,32 @@ export function DownloadSettingsModal({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
value={selectedPath}
|
||||
readOnly
|
||||
disabled
|
||||
label={t("download_path")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
disabled={downloadStarting}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
<TextField
|
||||
value={selectedPath}
|
||||
readOnly
|
||||
disabled
|
||||
label={t("download_path")}
|
||||
error={
|
||||
hasWritePermission === false ? (
|
||||
<span
|
||||
className={styles.pathError}
|
||||
data-open-article="cannot-write-directory"
|
||||
>
|
||||
{t("no_write_permission")}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
rightContent={
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
disabled={downloadStarting}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className={styles.hintText}>
|
||||
<Trans i18nKey="select_folder_hint" ns="game_details">
|
||||
@@ -205,7 +221,11 @@ export function DownloadSettingsModal({
|
||||
|
||||
<Button
|
||||
onClick={handleStartClick}
|
||||
disabled={downloadStarting || selectedDownloader === null}
|
||||
disabled={
|
||||
downloadStarting ||
|
||||
selectedDownloader === null ||
|
||||
!hasWritePermission
|
||||
}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t("download_now")}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { Game } from "@types";
|
||||
import * as styles from "./game-options-modal.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
import { useDownload, useToast } from "@renderer/hooks";
|
||||
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
|
||||
import { ResetAchievementsModal } from "./reset-achievements-modal";
|
||||
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
@@ -24,11 +26,20 @@ export function GameOptionsModal({
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { updateGame, setShowRepacksModal, repacks, selectGameExecutable } =
|
||||
useContext(gameDetailsContext);
|
||||
const {
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
repacks,
|
||||
selectGameExecutable,
|
||||
achievements,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
|
||||
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
|
||||
const [showResetAchievementsModal, setShowResetAchievementsModal] =
|
||||
useState(false);
|
||||
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
|
||||
|
||||
const {
|
||||
removeGameInstaller,
|
||||
@@ -37,6 +48,12 @@ export function GameOptionsModal({
|
||||
cancelDownload,
|
||||
} = useDownload();
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const hasAchievements =
|
||||
(achievements?.filter((achievement) => achievement.unlocked).length ?? 0) >
|
||||
0;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
@@ -44,6 +61,13 @@ export function GameOptionsModal({
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
|
||||
const debounceUpdateLaunchOptions = useRef(
|
||||
debounce(async (value: string) => {
|
||||
await window.electron.updateLaunchOptions(game.id, value);
|
||||
updateGame();
|
||||
}, 1000)
|
||||
).current;
|
||||
|
||||
const handleRemoveGameFromLibrary = async () => {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.id);
|
||||
@@ -95,6 +119,11 @@ export function GameOptionsModal({
|
||||
await window.electron.openGameExecutablePath(game.id);
|
||||
};
|
||||
|
||||
const handleClearExecutablePath = async () => {
|
||||
await window.electron.updateExecutablePath(game.id, null);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleChangeWinePrefixPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
@@ -106,9 +135,42 @@ export function GameOptionsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearWinePrefixPath = async () => {
|
||||
await window.electron.selectGameWinePrefix(game.id, null);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleChangeLaunchOptions = async (event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
setLaunchOptions(value);
|
||||
debounceUpdateLaunchOptions(value);
|
||||
};
|
||||
|
||||
const handleClearLaunchOptions = async () => {
|
||||
setLaunchOptions("");
|
||||
|
||||
window.electron.updateLaunchOptions(game.id, null).then(updateGame);
|
||||
};
|
||||
|
||||
const shouldShowWinePrefixConfiguration =
|
||||
window.electron.platform === "linux";
|
||||
|
||||
const handleResetAchievements = async () => {
|
||||
setIsDeletingAchievements(true);
|
||||
try {
|
||||
await window.electron.resetGameAchievements(game.id);
|
||||
await updateGame();
|
||||
showSuccessToast(t("reset_achievements_success"));
|
||||
} catch (error) {
|
||||
showErrorToast(t("reset_achievements_error"));
|
||||
} finally {
|
||||
setIsDeletingAchievements(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowLaunchOptionsConfiguration = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteGameModal
|
||||
@@ -124,6 +186,13 @@ export function GameOptionsModal({
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<ResetAchievementsModal
|
||||
visible={showResetAchievementsModal}
|
||||
onClose={() => setShowResetAchievementsModal(false)}
|
||||
resetAchievements={handleResetAchievements}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={game.title}
|
||||
@@ -145,14 +214,21 @@ export function GameOptionsModal({
|
||||
disabled
|
||||
placeholder={t("no_executable_selected")}
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleChangeExecutableLocation}
|
||||
>
|
||||
<FileIcon />
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleChangeExecutableLocation}
|
||||
>
|
||||
<FileIcon />
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
{game.executablePath && (
|
||||
<Button onClick={handleClearExecutablePath} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -186,14 +262,46 @@ export function GameOptionsModal({
|
||||
disabled
|
||||
placeholder={t("no_directory_selected")}
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleChangeWinePrefixPath}
|
||||
>
|
||||
<FileDirectoryIcon />
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleChangeWinePrefixPath}
|
||||
>
|
||||
<FileDirectoryIcon />
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
{game.winePrefixPath && (
|
||||
<Button
|
||||
onClick={handleClearWinePrefixPath}
|
||||
theme="outline"
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowLaunchOptionsConfiguration && (
|
||||
<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>
|
||||
@@ -240,6 +348,20 @@ export function GameOptionsModal({
|
||||
>
|
||||
{t("remove_from_library")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import * as styles from "./remove-from-library-modal.css";
|
||||
import type { Game } from "@types";
|
||||
type ResetAchievementsModalProps = Readonly<{
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
onClose: () => void;
|
||||
resetAchievements: () => Promise<void>;
|
||||
}>;
|
||||
|
||||
export function ResetAchievementsModal({
|
||||
onClose,
|
||||
game,
|
||||
visible,
|
||||
resetAchievements,
|
||||
}: ResetAchievementsModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const handleResetAchievements = async () => {
|
||||
try {
|
||||
await resetAchievements();
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title={t("reset_achievements_title")}
|
||||
description={t("reset_achievements_description", {
|
||||
game: game.title,
|
||||
})}
|
||||
>
|
||||
<div className={styles.deleteActionsButtonsCtn}>
|
||||
<Button onClick={handleResetAchievements} theme="outline">
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
|
||||
const fakeAchievements: UserAchievement[] = [
|
||||
{
|
||||
@@ -67,15 +68,10 @@ export function Sidebar() {
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
const {
|
||||
gameTitle,
|
||||
shopDetails,
|
||||
objectId,
|
||||
shop,
|
||||
stats,
|
||||
achievements,
|
||||
handleClickOpenCheckout,
|
||||
} = useContext(gameDetailsContext);
|
||||
const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
const { formatDateTime } = useDate();
|
||||
@@ -158,7 +154,7 @@ export function Sidebar() {
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
<small>
|
||||
{achievement.unlockTime &&
|
||||
{achievement.unlockTime != null &&
|
||||
formatDateTime(achievement.unlockTime)}
|
||||
</small>
|
||||
</div>
|
||||
@@ -179,7 +175,7 @@ export function Sidebar() {
|
||||
{!hasActiveSubscription && (
|
||||
<button
|
||||
className={styles.subscriptionRequiredButton}
|
||||
onClick={handleClickOpenCheckout}
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
>
|
||||
<CloudOfflineIcon size={16} />
|
||||
<span>{t("achievements_not_sync")}</span>
|
||||
@@ -207,7 +203,7 @@ export function Sidebar() {
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
<small>
|
||||
{achievement.unlockTime &&
|
||||
{achievement.unlockTime != null &&
|
||||
formatDateTime(achievement.unlockTime)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import { Button, GameCard, Hero } from "@renderer/components";
|
||||
import type { Steam250Game, CatalogueEntry } from "@types";
|
||||
import type { Steam250Game } from "@types";
|
||||
|
||||
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
|
||||
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
|
||||
@@ -15,14 +15,6 @@ import * as styles from "./home.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { CatalogueCategory } from "@shared";
|
||||
import { catalogueCacheTable, db } from "@renderer/dexie";
|
||||
import { add } from "date-fns";
|
||||
|
||||
const categoryCacheDurationInSeconds = {
|
||||
[CatalogueCategory.Hot]: 60 * 60 * 2,
|
||||
[CatalogueCategory.Weekly]: 60 * 60 * 24,
|
||||
[CatalogueCategory.Achievements]: 60 * 60 * 24,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation("home");
|
||||
@@ -36,9 +28,7 @@ export default function Home() {
|
||||
CatalogueCategory.Hot
|
||||
);
|
||||
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, CatalogueEntry[]>
|
||||
>({
|
||||
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
|
||||
[CatalogueCategory.Hot]: [],
|
||||
[CatalogueCategory.Weekly]: [],
|
||||
[CatalogueCategory.Achievements]: [],
|
||||
@@ -46,37 +36,11 @@ export default function Home() {
|
||||
|
||||
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
|
||||
try {
|
||||
const catalogueCache = await catalogueCacheTable
|
||||
.where("expiresAt")
|
||||
.above(new Date())
|
||||
.and((cache) => cache.category === category)
|
||||
.first();
|
||||
|
||||
setCurrentCatalogueCategory(category);
|
||||
setIsLoading(true);
|
||||
|
||||
if (catalogueCache)
|
||||
return setCatalogue((prev) => ({
|
||||
...prev,
|
||||
[category]: catalogueCache.games,
|
||||
}));
|
||||
|
||||
const catalogue = await window.electron.getCatalogue(category);
|
||||
|
||||
db.transaction("rw", catalogueCacheTable, async () => {
|
||||
await catalogueCacheTable.where("category").equals(category).delete();
|
||||
|
||||
await catalogueCacheTable.add({
|
||||
category,
|
||||
games: catalogue,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: add(new Date(), {
|
||||
seconds: categoryCacheDurationInSeconds[category],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { GameCard } from "@renderer/components";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import type { DebouncedFunc } from "lodash";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { clearSearch, setSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as styles from "./home.css";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
export default function SearchResults() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("home");
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTypingMessage, setShowTypingMessage] = useState(false);
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => void> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setSearch(searchParams.get("query") ?? ""));
|
||||
}, [dispatch, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
debouncedFunc.current = debounce(() => {
|
||||
const query = searchParams.get("query") ?? "";
|
||||
|
||||
if (query.length < 3) {
|
||||
setIsLoading(false);
|
||||
setShowTypingMessage(true);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowTypingMessage(false);
|
||||
window.electron
|
||||
.searchGames(query)
|
||||
.then((results) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
setSearchResults(results);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
debouncedFunc.current();
|
||||
}, [searchParams, dispatch]);
|
||||
|
||||
const noResultsContent = () => {
|
||||
if (isLoading) return null;
|
||||
|
||||
if (showTypingMessage) {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<SearchIcon size={56} />
|
||||
|
||||
<p>{t("start_typing")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<InboxIcon size={56} />
|
||||
|
||||
<p>{t("no_results")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section className={styles.content}>
|
||||
<section className={styles.cards}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))}
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{noResultsContent()}
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export function EditProfileModal(
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
error={errors.displayName}
|
||||
error={errors.displayName?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { userProfileContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import * as styles from "./profile-content.css";
|
||||
import { Avatar, Link } from "@renderer/components";
|
||||
|
||||
@@ -13,6 +13,21 @@ export function FriendsBox() {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||
if (game.iconUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={game.title}
|
||||
width={16}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={game.iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SteamLogo width={16} height={16} />;
|
||||
};
|
||||
|
||||
if (!userProfile?.friends.length) return null;
|
||||
|
||||
return (
|
||||
@@ -27,7 +42,14 @@ export function FriendsBox() {
|
||||
<div className={styles.box}>
|
||||
<ul className={styles.list}>
|
||||
{userProfile?.friends.map((friend) => (
|
||||
<li key={friend.id}>
|
||||
<li
|
||||
key={friend.id}
|
||||
title={
|
||||
friend.currentGame
|
||||
? t("playing", { game: friend.currentGame.title })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
|
||||
<Avatar
|
||||
size={32}
|
||||
@@ -35,7 +57,19 @@ export function FriendsBox() {
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
|
||||
<span className={styles.friendName}>{friend.displayName}</span>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span className={styles.friendName}>
|
||||
{friend.displayName}
|
||||
</span>
|
||||
{friend.currentGame && (
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{getGameImage(friend.currentGame)}
|
||||
<small>{friend.currentGame.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -105,6 +105,22 @@ export const listItem = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const statsListItem = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "all ease 0.1s",
|
||||
color: vars.color.muted,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export const gamesGrid = style({
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
@@ -203,3 +219,20 @@ export const achievementsProgressBar = style({
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
|
||||
export const link = style({
|
||||
textAlign: "start",
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
||||
export const gameCardStats = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transition: "transform 0.5s ease-in-out",
|
||||
flexShrink: "0",
|
||||
flexGrow: "0",
|
||||
});
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import * as styles from "./profile-content.css";
|
||||
import { ClockIcon, TelescopeIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
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";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import type { UserGame } from "@types";
|
||||
import {
|
||||
buildGameAchievementPath,
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||
|
||||
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
||||
|
||||
export function ProfileContent() {
|
||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||
const [statsIndex, setStatsIndex] = useState(0);
|
||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||
const statsAnimation = useRef(-1);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -37,6 +35,35 @@ export function ProfileContent() {
|
||||
}
|
||||
}, [userProfile, dispatch]);
|
||||
|
||||
const handleOnMouseEnterGameCard = () => {
|
||||
setIsAnimationRunning(false);
|
||||
};
|
||||
|
||||
const handleOnMouseLeaveGameCard = () => {
|
||||
setIsAnimationRunning(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let zero = performance.now();
|
||||
if (!isAnimationRunning) return;
|
||||
|
||||
statsAnimation.current = requestAnimationFrame(
|
||||
function animateGameStats(time) {
|
||||
if (time - zero <= GAME_STATS_ANIMATION_DURATION_IN_MS) {
|
||||
statsAnimation.current = requestAnimationFrame(animateGameStats);
|
||||
} else {
|
||||
setStatsIndex((index) => index + 1);
|
||||
zero = performance.now();
|
||||
statsAnimation.current = requestAnimationFrame(animateGameStats);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(statsAnimation.current);
|
||||
};
|
||||
}, [setStatsIndex, isAnimationRunning]);
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -45,42 +72,6 @@ export function ProfileContent() {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
|
||||
const buildUserGameDetailsPath = useCallback(
|
||||
(game: UserGame) => {
|
||||
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
|
||||
return buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
}
|
||||
|
||||
const userParams = userProfile
|
||||
? {
|
||||
userId: userProfile.id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return buildGameAchievementPath({ ...game }, userParams);
|
||||
},
|
||||
[userProfile]
|
||||
);
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds = 0) => {
|
||||
const minutes = playTimeInSeconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!userProfile) return null;
|
||||
|
||||
@@ -127,118 +118,13 @@ export function ProfileContent() {
|
||||
|
||||
<ul className={styles.gamesGrid}>
|
||||
{userProfile?.libraryGames?.map((game) => (
|
||||
<li
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
key={game.objectId}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}
|
||||
className={styles.game}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={styles.gameCover}
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<small
|
||||
style={{
|
||||
backgroundColor: vars.color.background,
|
||||
color: vars.color.muted,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
<ClockIcon size={11} />
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
|
||||
{userProfile.hasActiveSubscription &&
|
||||
game.achievementCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
color: vars.color.muted,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{game.unlockedAchievementCount} /{" "}
|
||||
{game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount /
|
||||
game.achievementCount
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
game.unlockedAchievementCount /
|
||||
game.achievementCount
|
||||
}
|
||||
className={styles.achievementsProgressBar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={steamUrlBuilder.cover(game.objectId)}
|
||||
alt={game.title}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
@@ -247,9 +133,9 @@ export function ProfileContent() {
|
||||
|
||||
{shouldShowRightContent && (
|
||||
<div className={styles.rightContent}>
|
||||
<UserStatsBox />
|
||||
<RecentGamesBox />
|
||||
<FriendsBox />
|
||||
|
||||
<ReportProfile />
|
||||
</div>
|
||||
)}
|
||||
@@ -262,9 +148,8 @@ export function ProfileContent() {
|
||||
userStats,
|
||||
numberFormatter,
|
||||
t,
|
||||
buildUserGameDetailsPath,
|
||||
formatPlayTime,
|
||||
navigate,
|
||||
statsIndex,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { UserGame } from "@types";
|
||||
import * as styles from "./profile-content.css";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useContext } from "react";
|
||||
import {
|
||||
buildGameAchievementPath,
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
interface UserLibraryGameCardProps {
|
||||
game: UserGame;
|
||||
statIndex: number;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export function UserLibraryGameCard({
|
||||
game,
|
||||
statIndex,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile } = useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getStatsItemCount = useCallback(() => {
|
||||
let statsCount = 1;
|
||||
if (game.achievementsPointsEarnedSum > 0) statsCount++;
|
||||
return statsCount;
|
||||
}, [game]);
|
||||
|
||||
const buildUserGameDetailsPath = useCallback(
|
||||
(game: UserGame) => {
|
||||
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
|
||||
return buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
}
|
||||
|
||||
const userParams = userProfile
|
||||
? {
|
||||
userId: userProfile.id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return buildGameAchievementPath({ ...game }, userParams);
|
||||
},
|
||||
[userProfile]
|
||||
);
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds = 0) => {
|
||||
const minutes = playTimeInSeconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}
|
||||
title={game.title}
|
||||
className={styles.game}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={styles.gameCover}
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(0, 0, 0, 0.70) 20%, transparent 100%)",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<small
|
||||
style={{
|
||||
backgroundColor: vars.color.background,
|
||||
color: vars.color.muted,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
<ClockIcon size={11} />
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
|
||||
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
color: vars.color.muted,
|
||||
overflow: "hidden",
|
||||
height: 18,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.gameCardStats}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{game.unlockedAchievementCount} / {game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
className={styles.gameCardStats}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 5,
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{formatAchievementPoints(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount / game.achievementCount,
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={game.unlockedAchievementCount / game.achievementCount}
|
||||
className={styles.achievementsProgressBar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={steamUrlBuilder.cover(game.objectId)}
|
||||
alt={game.title}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minWidth: "100%",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as styles from "./profile-content.css";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
export function UserStatsBox() {
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { userStats, isMe } = useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds: number) => {
|
||||
const seconds = playTimeInSeconds;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
if (!userStats) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>{t("stats")}</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.box}>
|
||||
<ul className={styles.list}>
|
||||
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>
|
||||
{t("achievements_unlocked")}
|
||||
</h3>
|
||||
{userStats.unlockedAchievementSum !== undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<p className={styles.listItemDescription}>
|
||||
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
|
||||
{t("achievements")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("show_achievements_on_profile")}
|
||||
</small>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>{t("earned_points")}</h3>
|
||||
{userStats.achievementsPointsEarnedSum !== undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<p className={styles.listItemDescription}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{numberFormatter.format(
|
||||
userStats.achievementsPointsEarnedSum.value
|
||||
)}
|
||||
</p>
|
||||
<p title={t("ranking_updated_weekly")}>
|
||||
{t("top_percentile", {
|
||||
percentile:
|
||||
userStats.achievementsPointsEarnedSum.topPercentile,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements-points")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("show_points_on_profile")}
|
||||
</small>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>{t("total_play_time")}</h3>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<p className={styles.listItemDescription}>
|
||||
<ClockIcon />
|
||||
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
|
||||
</p>
|
||||
<p title={t("ranking_updated_weekly")}>
|
||||
{t("top_percentile", {
|
||||
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function ReportProfile() {
|
||||
{...register("description")}
|
||||
label={t("report_description")}
|
||||
placeholder={t("report_description_placeholder")}
|
||||
error={errors.description}
|
||||
error={errors.description?.message}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -100,17 +100,30 @@ export function AddDownloadSourceModal({
|
||||
}
|
||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
setIsLoading(true);
|
||||
const putDownloadSource = async () => {
|
||||
const downloadSource = await downloadSourcesTable.where({ url }).first();
|
||||
if (!downloadSource) return;
|
||||
|
||||
window.electron
|
||||
.putDownloadSource(downloadSource.objectIds)
|
||||
.then(({ fingerprint }) => {
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
if (validationResult) {
|
||||
setIsLoading(true);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
channel.onmessage = async () => {
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
channel.close();
|
||||
@@ -137,7 +150,7 @@ export function AddDownloadSourceModal({
|
||||
{...register("url")}
|
||||
label={t("download_source_url")}
|
||||
placeholder={t("insert_valid_json_url")}
|
||||
error={errors.url}
|
||||
error={errors.url?.message}
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -5,14 +5,7 @@ import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
export const form = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const blockedUserAvatar = style({
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "4px",
|
||||
filter: "grayscale(100%)",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
});
|
||||
|
||||
export const blockedUser = style({
|
||||
@@ -43,5 +36,4 @@ export const blockedUsersList = style({
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
});
|
||||
291
src/renderer/src/pages/settings/settings-account.tsx
Normal file
291
src/renderer/src/pages/settings/settings-account.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Avatar, Button, SelectField } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-account.css";
|
||||
import { useDate, useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
CloudIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
XCircleFillIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
interface FormValues {
|
||||
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
||||
}
|
||||
|
||||
export function SettingsAccount() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
|
||||
|
||||
const { formatDate } = useDate();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
handleSubmit,
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
hasActiveSubscription,
|
||||
patchUser,
|
||||
fetchUserDetails,
|
||||
updateUserDetails,
|
||||
unblockUser,
|
||||
} = useUserDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.profileVisibility) {
|
||||
setValue("profileVisibility", userDetails.profileVisibility);
|
||||
}
|
||||
}, [userDetails, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAccountUpdated(() => {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
}
|
||||
});
|
||||
showSuccessToast(t("account_data_updated_successfully"));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchUserDetails, updateUserDetails]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
{ value: "PRIVATE", label: t("private") },
|
||||
];
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await patchUser(values);
|
||||
showSuccessToast(t("changes_saved"));
|
||||
};
|
||||
|
||||
const handleUnblockClick = useCallback(
|
||||
(id: string) => {
|
||||
setIsUnblocking(true);
|
||||
|
||||
unblockUser(id)
|
||||
.then(() => {
|
||||
fetchBlockedUsers();
|
||||
showSuccessToast(t("user_unblocked"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUnblocking(false);
|
||||
});
|
||||
},
|
||||
[unblockUser, fetchBlockedUsers, t, showSuccessToast]
|
||||
);
|
||||
|
||||
const getHydraCloudSectionContent = () => {
|
||||
const hasSubscribedBefore = Boolean(userDetails?.subscription?.expiresAt);
|
||||
const isRenewalActive = userDetails?.subscription?.status === "active";
|
||||
|
||||
if (!hasSubscribedBefore) {
|
||||
return {
|
||||
description: <small>{t("no_subscription")}</small>,
|
||||
callToAction: t("become_subscriber"),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
return {
|
||||
description: isRenewalActive ? (
|
||||
<>
|
||||
<small>
|
||||
{t("subscription_renews_on", {
|
||||
date: formatDate(userDetails.subscription!.expiresAt!),
|
||||
})}
|
||||
</small>
|
||||
<small>{t("bill_sent_until")}</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<small>{t("subscription_renew_cancelled")}</small>
|
||||
<small>
|
||||
{t("subscription_active_until", {
|
||||
date: formatDate(userDetails!.subscription!.expiresAt!),
|
||||
})}
|
||||
</small>
|
||||
</>
|
||||
),
|
||||
callToAction: t("manage_subscription"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: (
|
||||
<small>
|
||||
{t("subscription_expired_at", {
|
||||
date: formatDate(userDetails!.subscription!.expiresAt!),
|
||||
})}
|
||||
</small>
|
||||
),
|
||||
callToAction: t("renew_subscription"),
|
||||
};
|
||||
};
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileVisibility"
|
||||
render={({ field }) => {
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
field.onChange(event);
|
||||
handleSubmit(onSubmit)();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SelectField
|
||||
label={t("profile_visibility")}
|
||||
value={field.value}
|
||||
onChange={handleChange}
|
||||
options={visibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<small>{t("profile_visibility_description")}</small>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h4>{t("current_email")}</h4>
|
||||
<p>{userDetails?.email ?? t("no_email_account")}</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
marginTop: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => window.electron.openAuthWindow(AuthPage.UpdateEmail)}
|
||||
>
|
||||
<MailIcon />
|
||||
{t("update_email")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
window.electron.openAuthWindow(AuthPage.UpdatePassword)
|
||||
}
|
||||
>
|
||||
<KeyIcon />
|
||||
{t("update_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h3>Hydra Cloud</h3>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{getHydraCloudSectionContent().description}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
style={{
|
||||
placeSelf: "flex-start",
|
||||
}}
|
||||
theme="outline"
|
||||
onClick={() => window.electron.openCheckout()}
|
||||
>
|
||||
<CloudIcon />
|
||||
{getHydraCloudSectionContent().callToAction}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h3>{t("blocked_users")}</h3>
|
||||
|
||||
{blockedUsers.length > 0 ? (
|
||||
<ul className={styles.blockedUsersList}>
|
||||
{blockedUsers.map((user) => {
|
||||
return (
|
||||
<li key={user.id} className={styles.blockedUser}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
style={{ filter: "grayscale(100%)" }}
|
||||
size={32}
|
||||
src={user.profileImageUrl}
|
||||
alt={user.displayName}
|
||||
/>
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unblockButton}
|
||||
onClick={() => handleUnblockClick(user.id)}
|
||||
disabled={isUnblocking}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<small>{t("no_users_blocked")}</small>
|
||||
)}
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export function SettingsBehavior() {
|
||||
runAtStartup: false,
|
||||
startMinimized: false,
|
||||
disableNsfwAlert: false,
|
||||
seedAfterDownloadComplete: false,
|
||||
showHiddenAchievementsDescription: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -30,6 +32,9 @@ export function SettingsBehavior() {
|
||||
runAtStartup: userPreferences.runAtStartup,
|
||||
startMinimized: userPreferences.startMinimized,
|
||||
disableNsfwAlert: userPreferences.disableNsfwAlert,
|
||||
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
@@ -96,6 +101,27 @@ export function SettingsBehavior() {
|
||||
handleChange({ disableNsfwAlert: !form.disableNsfwAlert })
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("seed_after_download_complete")}
|
||||
checked={form.seedAfterDownloadComplete}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
seedAfterDownloadComplete: !form.seedAfterDownloadComplete,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("show_hidden_achievement_description")}
|
||||
checked={form.showHiddenAchievementsDescription}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
showHiddenAchievementsDescription:
|
||||
!form.showHiddenAchievementsDescription,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,3 +42,17 @@ export const downloadSourcesHeader = style({
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const navigateToCatalogueButton = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.muted,
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
|
||||
":disabled": {
|
||||
cursor: "default",
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,12 +7,13 @@ import * as styles from "./settings-download-sources.css";
|
||||
import type { DownloadSource } from "@types";
|
||||
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
|
||||
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { repacksContext, settingsContext } from "@renderer/context";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { setFilters, clearFilters } from "@renderer/features";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
|
||||
@@ -28,7 +29,11 @@ export function SettingsDownloadSources() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await downloadSourcesTable
|
||||
@@ -57,16 +62,16 @@ export function SettingsDownloadSources() {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
|
||||
getDownloadSources();
|
||||
indexRepacks();
|
||||
setIsRemovingDownloadSource(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
indexRepacks();
|
||||
await getDownloadSources();
|
||||
showSuccessToast(t("added_download_source"));
|
||||
updateRepacks();
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
@@ -82,6 +87,7 @@ export function SettingsDownloadSources() {
|
||||
getDownloadSources();
|
||||
setIsSyncingDownloadSources(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,6 +101,13 @@ export function SettingsDownloadSources() {
|
||||
setShowAddDownloadSourceModal(false);
|
||||
};
|
||||
|
||||
const navigateToCatalogue = (fingerprint: string) => {
|
||||
dispatch(clearFilters());
|
||||
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
|
||||
|
||||
navigate("/catalogue");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddDownloadSourceModal
|
||||
@@ -146,12 +159,11 @@ export function SettingsDownloadSources() {
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navigateToCatalogueButton}
|
||||
disabled={!downloadSource.fingerprint}
|
||||
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
@@ -160,7 +172,7 @@ export function SettingsDownloadSources() {
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { SelectField } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-privacy.css";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { XCircleFillIcon } from "@primer/octicons-react";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
interface FormValues {
|
||||
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
||||
}
|
||||
|
||||
export function SettingsPrivacy() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
handleSubmit,
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { patchUser, userDetails } = useUserDetails();
|
||||
|
||||
const { unblockUser } = useUserDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.profileVisibility) {
|
||||
setValue("profileVisibility", userDetails.profileVisibility);
|
||||
}
|
||||
}, [userDetails, setValue]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
{ value: "PRIVATE", label: t("private") },
|
||||
];
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await patchUser(values);
|
||||
showSuccessToast(t("changes_saved"));
|
||||
};
|
||||
|
||||
const handleUnblockClick = useCallback(
|
||||
(id: string) => {
|
||||
setIsUnblocking(true);
|
||||
|
||||
unblockUser(id)
|
||||
.then(() => {
|
||||
fetchBlockedUsers();
|
||||
showSuccessToast(t("user_unblocked"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUnblocking(false);
|
||||
});
|
||||
},
|
||||
[unblockUser, fetchBlockedUsers, t, showSuccessToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileVisibility"
|
||||
render={({ field }) => {
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
field.onChange(event);
|
||||
handleSubmit(onSubmit)();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
label={t("profile_visibility")}
|
||||
value={field.value}
|
||||
onChange={handleChange}
|
||||
options={visibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<small>{t("profile_visibility_description")}</small>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
{t("blocked_users")}
|
||||
</h3>
|
||||
|
||||
<ul className={styles.blockedUsersList}>
|
||||
{blockedUsers.map((user) => {
|
||||
return (
|
||||
<li key={user.id} className={styles.blockedUser}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.profileImageUrl!}
|
||||
alt={user.displayName}
|
||||
className={styles.blockedUserAvatar}
|
||||
/>
|
||||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unblockButton}
|
||||
onClick={() => handleUnblockClick(user.id)}
|
||||
disabled={isUnblocking}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SettingsContextConsumer,
|
||||
SettingsContextProvider,
|
||||
} from "@renderer/context";
|
||||
import { SettingsPrivacy } from "./settings-privacy";
|
||||
import { SettingsAccount } from "./settings-account";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Settings() {
|
||||
"Real-Debrid",
|
||||
];
|
||||
|
||||
if (userDetails) return [...categories, t("privacy")];
|
||||
if (userDetails) return [...categories, t("account")];
|
||||
return categories;
|
||||
}, [userDetails, t]);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Settings() {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsPrivacy />;
|
||||
return <SettingsAccount />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface HydraCloudModalProps {
|
||||
feature: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const HydraCloudModal = ({
|
||||
feature,
|
||||
visible,
|
||||
onClose,
|
||||
}: HydraCloudModalProps) => {
|
||||
const { t } = useTranslation("hydra_cloud");
|
||||
|
||||
const handleClickOpenCheckout = () => {
|
||||
window.electron.openCheckout();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={t("hydra_cloud")} onClose={onClose}>
|
||||
<div
|
||||
data-hydra-cloud-feature={feature}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "500px",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{t("hydra_cloud_feature_found")}
|
||||
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -4,9 +4,9 @@ $dark-background-color: #151515;
|
||||
$muted-color: #c0c1c7;
|
||||
$body-color: #8e919b;
|
||||
|
||||
$border-color: #424244;
|
||||
$border-color: rgba(255, 255, 255, 0.15);
|
||||
$success-color: #1c9749;
|
||||
$danger-color: #e11d48;
|
||||
$danger-color: #801d1e;
|
||||
$warning-color: #ffc107;
|
||||
|
||||
$disabled-opacity: 0.5;
|
||||
|
||||
@@ -3,16 +3,17 @@ import {
|
||||
downloadSlice,
|
||||
windowSlice,
|
||||
librarySlice,
|
||||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
userDetailsSlice,
|
||||
gameRunningSlice,
|
||||
subscriptionSlice,
|
||||
repacksSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
search: searchSlice.reducer,
|
||||
window: windowSlice.reducer,
|
||||
library: librarySlice.reducer,
|
||||
userPreferences: userPreferencesSlice.reducer,
|
||||
@@ -20,6 +21,9 @@ export const store = configureStore({
|
||||
toast: toastSlice.reducer,
|
||||
userDetails: userDetailsSlice.reducer,
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
subscription: subscriptionSlice.reducer,
|
||||
repacks: repacksSlice.reducer,
|
||||
catalogueSearch: catalogueSearchSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const vars = createGlobalTheme(":root", {
|
||||
darkBackground: "#151515",
|
||||
muted: "#c0c1c7",
|
||||
body: "#8e919b",
|
||||
border: "#424244",
|
||||
border: "rgba(255, 255, 255, 0.15)",
|
||||
success: "#1c9749",
|
||||
danger: "#e11d48",
|
||||
warning: "#ffc107",
|
||||
|
||||
1
src/renderer/src/vite-env.d.ts
vendored
1
src/renderer/src/vite-env.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly RENDERER_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||
readonly RENDERER_VITE_SENTRY_DSN: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
|
||||
|
||||
import { z } from "zod";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { DownloadSourceStatus, formatName, pipe } from "@shared";
|
||||
import { GameRepack } from "@types";
|
||||
|
||||
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
|
||||
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
@@ -22,6 +25,95 @@ type Payload =
|
||||
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
||||
| ["SYNC_DOWNLOAD_SOURCES", string];
|
||||
|
||||
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
|
||||
|
||||
const addNewDownloads = async (
|
||||
downloadSource: { id: number; name: string },
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
|
||||
steamGames: SteamGamesByLetter
|
||||
) => {
|
||||
const now = new Date();
|
||||
|
||||
const results = [] as (Omit<GameRepack, "id"> & {
|
||||
downloadSourceId: number;
|
||||
})[];
|
||||
|
||||
const objectIdsOnSource = new Set<string>();
|
||||
|
||||
for (const download of downloads) {
|
||||
const formattedTitle = formatRepackName(download.title);
|
||||
const [firstLetter] = formattedTitle;
|
||||
const games = steamGames[firstLetter] || [];
|
||||
|
||||
const gamesInSteam = games.filter((game) =>
|
||||
formattedTitle.startsWith(game.name)
|
||||
);
|
||||
|
||||
if (gamesInSteam.length === 0) continue;
|
||||
|
||||
for (const game of gamesInSteam) {
|
||||
objectIdsOnSource.add(String(game.id));
|
||||
}
|
||||
|
||||
results.push({
|
||||
objectIds: gamesInSteam.map((game) => String(game.id)),
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await repacksTable.bulkAdd(results);
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
objectIds: Array.from(objectIdsOnSource),
|
||||
});
|
||||
};
|
||||
|
||||
const getSteamGames = async () => {
|
||||
const response = await axios.get<SteamGamesByLetter>(
|
||||
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const importDownloadSource = async (url: string) => {
|
||||
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteDownloadSource = async (id: number) => {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: id }).delete();
|
||||
await downloadSourcesTable.where({ id }).delete();
|
||||
});
|
||||
};
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const [type, data] = event.data;
|
||||
|
||||
@@ -41,10 +133,7 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
}
|
||||
|
||||
if (type === "DELETE_DOWNLOAD_SOURCE") {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: data }).delete();
|
||||
await downloadSourcesTable.where({ id: data }).delete();
|
||||
});
|
||||
await deleteDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
|
||||
|
||||
@@ -52,37 +141,7 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
}
|
||||
|
||||
if (type === "IMPORT_DOWNLOAD_SOURCE") {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url: data,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
const repacks = response.data.downloads.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: response.data.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource!.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
});
|
||||
await importDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
||||
channel.postMessage(true);
|
||||
@@ -96,64 +155,62 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
const existingRepacks = await repacksTable.toArray();
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
const headers = new AxiosHeaders();
|
||||
if (downloadSources.some((source) => !source.fingerprint)) {
|
||||
await Promise.all(
|
||||
downloadSources.map(async (source) => {
|
||||
await deleteDownloadSource(source.id);
|
||||
await importDownloadSource(source.url);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
for (const downloadSource of downloadSources) {
|
||||
const headers = new AxiosHeaders();
|
||||
|
||||
if (downloadSource.etag) {
|
||||
headers.set("If-None-Match", downloadSource.etag);
|
||||
}
|
||||
if (downloadSource.etag) {
|
||||
headers.set("If-None-Match", downloadSource.etag);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(downloadSource.url, {
|
||||
headers,
|
||||
});
|
||||
try {
|
||||
const response = await axios.get(downloadSource.url, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
downloadSourcesTable,
|
||||
async () => {
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: source.downloads.length,
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
const now = new Date();
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
downloadSourcesTable,
|
||||
async () => {
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: source.downloads.length,
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
|
||||
const repacks = source.downloads
|
||||
.filter(
|
||||
const repacks = source.downloads.filter(
|
||||
(download) =>
|
||||
!existingRepacks.some(
|
||||
(repack) => repack.title === download.title
|
||||
)
|
||||
)
|
||||
.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: source.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
);
|
||||
|
||||
newRepacksCount += repacks.length;
|
||||
await addNewDownloads(downloadSource, repacks, steamGames);
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||
newRepacksCount += repacks.length;
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
status: isNotModified
|
||||
? DownloadSourceStatus.UpToDate
|
||||
: DownloadSourceStatus.Errored,
|
||||
});
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
status: isNotModified
|
||||
? DownloadSourceStatus.UpToDate
|
||||
: DownloadSourceStatus.Errored,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import RepacksWorker from "./repacks.worker?worker";
|
||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||
|
||||
export const repacksWorker = new RepacksWorker();
|
||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { formatName } from "@shared";
|
||||
import type { GameRepack } from "@types";
|
||||
import flexSearch from "flexsearch";
|
||||
|
||||
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
|
||||
uris: string;
|
||||
}
|
||||
|
||||
const state = {
|
||||
repacks: [] as SerializedGameRepack[],
|
||||
index: null as flexSearch.Index | null,
|
||||
};
|
||||
|
||||
self.onmessage = async (
|
||||
event: MessageEvent<[string, string] | "INDEX_REPACKS">
|
||||
) => {
|
||||
if (event.data === "INDEX_REPACKS") {
|
||||
state.index = new flexSearch.Index();
|
||||
|
||||
repacksTable
|
||||
.toCollection()
|
||||
.sortBy("uploadDate")
|
||||
.then((results) => {
|
||||
state.repacks = results.reverse();
|
||||
|
||||
for (let i = 0; i < state.repacks.length; i++) {
|
||||
const repack = state.repacks[i];
|
||||
const formattedTitle = formatName(repack.title);
|
||||
state.index!.add(i, formattedTitle);
|
||||
}
|
||||
|
||||
self.postMessage("INDEXING_COMPLETE");
|
||||
});
|
||||
} else {
|
||||
const [requestId, query] = event.data;
|
||||
|
||||
const results = state.index!.search(formatName(query)).map((index) => {
|
||||
const repack = state.repacks.at(index as number) as SerializedGameRepack;
|
||||
|
||||
return {
|
||||
...repack,
|
||||
uris: [...repack.uris, repack.magnet].filter(Boolean),
|
||||
};
|
||||
});
|
||||
|
||||
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
|
||||
|
||||
channel.postMessage(results);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user