Merge branch 'main' into hotfix-game-minimun-specs-accordion

This commit is contained in:
Chubby Granny Chaser
2025-01-17 15:18:28 +00:00
committed by GitHub
227 changed files with 9139 additions and 3328 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

@@ -10,5 +10,5 @@ export function useFormat() {
});
}, [i18n.language]);
return { numberFormatter };
return { numberFormatter, formatNumber: numberFormatter.format };
}

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,3 +36,10 @@ export const downloaderIcon = style({
position: "absolute",
left: `${SPACING_UNIT * 2}px`,
});
export const pathError = style({
cursor: "pointer",
":hover": {
textDecoration: "underline",
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -163,7 +163,7 @@ export function EditProfileModal(
minLength={3}
maxLength={50}
containerProps={{ style: { width: "100%" } }}
error={errors.displayName}
error={errors.displayName?.message}
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly RENDERER_VITE_EXTERNAL_RESOURCES_URL: string;
readonly RENDERER_VITE_SENTRY_DSN: string;
}
interface ImportMeta {

View File

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

View File

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

View File

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