Merge branch 'main' into feature/torbox-integration

# Conflicts:
#	src/renderer/src/constants.ts
#	src/shared/constants.ts
This commit is contained in:
Zamitto
2025-01-11 15:19:01 -03:00
61 changed files with 2170 additions and 765 deletions

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

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

@@ -8,6 +8,7 @@ export const DOWNLOADER_NAME = {
[Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.TorBox]: "TorBox",
};

View File

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

View File

@@ -31,7 +31,7 @@ import type {
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" {
@@ -93,6 +93,10 @@ declare global {
id: number,
executablePath: string | null
) => Promise<void>;
updateLaunchOptions: (
id: number,
launchOptions: string | null
) => Promise<void>;
selectGameWinePrefix: (
id: number,
winePrefixPath: string | null
@@ -102,7 +106,11 @@ declare global {
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>;
@@ -114,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: (
@@ -132,7 +140,8 @@ declare global {
) => Promise<{ fingerprint: string }>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
checkFolderWritePermission: (path: string) => Promise<boolean>;
/* Cloud save */
uploadSaveGame: (
@@ -187,6 +196,7 @@ declare global {
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>;
platform: NodeJS.Platform;
/* Auto update */

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

@@ -6,3 +6,4 @@ 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,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

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

@@ -34,6 +34,19 @@ 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();

View File

@@ -31,11 +31,11 @@ export function GameItem({ game }: GameItemProps) {
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"].findIndex(
const index = steamGenres["en"]?.findIndex(
(steamGenre) => steamGenre === genre
);
if (steamGenres[language] && steamGenres[language][index]) {
if (index && steamGenres[language] && steamGenres[language][index]) {
return steamGenres[language][index];
}

View File

@@ -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,14 +143,12 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
restoringBackup,
loadingPreview,
artifacts,
backupsPerGameLimit,
t,
]);
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
const userDetails = useAppSelector((state) => state.userDetails.userDetails);
const backupsPerGameLimit = userDetails?.quirks.backupsPerGameLimit ?? 0;
return (
<Modal
visible={visible}

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,7 +1,6 @@
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";
@@ -10,7 +9,7 @@ import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
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 ?? []);
@@ -82,12 +105,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,
@@ -122,7 +139,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}
>
@@ -166,23 +183,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">
@@ -193,7 +219,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);
@@ -116,9 +140,37 @@ export function GameOptionsModal({
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
@@ -134,6 +186,13 @@ export function GameOptionsModal({
game={game}
/>
<ResetAchievementsModal
visible={showResetAchievementsModal}
onClose={() => setShowResetAchievementsModal(false)}
resetAchievements={handleResetAchievements}
game={game}
/>
<Modal
visible={visible}
title={game.title}
@@ -226,6 +285,28 @@ export function GameOptionsModal({
</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>
)}
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
@@ -267,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

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

@@ -228,3 +228,11 @@ export const link = style({
cursor: "pointer",
},
});
export const gameCardStats = style({
width: "100%",
height: "100%",
transition: "transform 0.5s ease-in-out",
flexShrink: "0",
flexGrow: "0",
});

View File

@@ -1,31 +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 HydraIcon from "@renderer/assets/icons/hydra.svg?react";
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();
@@ -39,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();
@@ -47,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;
@@ -129,137 +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",
}}
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.75) 25%, 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: "#fff",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
{game.achievementsPointsEarnedSum > 0 && (
<div
style={{
display: "flex",
justifyContent: "start",
gap: 8,
marginBottom: 4,
color: vars.color.muted,
}}
>
<HydraIcon width={16} height={16} />
{numberFormatter.format(
game.achievementsPointsEarnedSum
)}
</div>
)}
<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%",
minWidth: "100%",
minHeight: "100%",
}}
/>
</button>
</li>
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
</>
@@ -271,7 +136,6 @@ export function ProfileContent() {
<UserStatsBox />
<RecentGamesBox />
<FriendsBox />
<ReportProfile />
</div>
)}
@@ -284,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

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

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

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