Merge branch 'main' into feature-add-low-price-and-game-language

This commit is contained in:
Daniel Saraiva
2025-09-19 13:20:18 -03:00
17 changed files with 438 additions and 70 deletions

View File

@@ -162,6 +162,11 @@ declare global {
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
changeGamePlayTime: (
shop: GameShop,
objectId: string,
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;

View File

@@ -12,4 +12,15 @@
color: globals.$body-color;
text-decoration: underline;
}
&__play-time {
display: flex;
align-items: center;
gap: 8px;
}
&__manual-warning {
color: #f59e0b; // Warning amber color
flex-shrink: 0;
}
}

View File

@@ -5,10 +5,14 @@ import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { AlertFillIcon } from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
@@ -85,7 +89,22 @@ export function HeroPanelPlaytime() {
return (
<>
<p>
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
{t("play_time", {
amount: formattedPlayTime,
})}
@@ -100,6 +119,17 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
style={{
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
@use "../../../scss/globals.scss";
.change-game-playtime-modal {
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit * 2;
}
&__warning {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit;
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 14px;
svg {
flex-shrink: 0;
}
}
&__inputs {
display: flex;
gap: globals.$spacing-unit;
align-items: flex-end;
}
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,168 @@
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import { useState, useEffect } from "react";
import { AlertIcon } from "@primer/octicons-react";
import "./change-game-playtime-modal.scss";
export interface ChangeGamePlaytimeModalProps {
visible: boolean;
game: Game;
onClose: () => void;
changePlaytime: (playTimeInSeconds: number) => Promise<void>;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
}
export function ChangeGamePlaytimeModal({
onClose,
game,
visible,
changePlaytime,
onSuccess,
onError,
}: Readonly<ChangeGamePlaytimeModalProps>) {
const { t } = useTranslation("game_details");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (visible && game.playTimeInMilliseconds) {
const totalMinutes = Math.floor(
game.playTimeInMilliseconds / (1000 * 60)
);
const currentHours = Math.floor(totalMinutes / 60);
const currentMinutes = totalMinutes % 60;
setHours(currentHours.toString());
setMinutes(currentMinutes.toString());
} else if (visible) {
setHours("");
setMinutes("");
}
}, [visible, game.playTimeInMilliseconds]);
const MAX_TOTAL_HOURS = 10000;
const currentHours = parseInt(hours) || 0;
const currentMinutes = parseInt(minutes) || 0;
const maxAllowedHours = Math.min(
MAX_TOTAL_HOURS,
Math.floor(MAX_TOTAL_HOURS - currentMinutes / 60)
);
const maxAllowedMinutes =
currentHours >= MAX_TOTAL_HOURS
? 0
: Math.min(59, Math.floor((MAX_TOTAL_HOURS - currentHours) * 60));
const handleChangePlaytime = async () => {
const hoursNum = parseInt(hours) || 0;
const minutesNum = parseInt(minutes) || 0;
const totalSeconds = hoursNum * 3600 + minutesNum * 60;
if (totalSeconds < 0) return;
if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
setIsSubmitting(true);
try {
await changePlaytime(totalSeconds);
onSuccess?.(t("update_playtime_success"));
onClose();
} catch (error) {
console.log(error);
onError?.(t("update_playtime_error"));
} finally {
setIsSubmitting(false);
}
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedHours) {
setHours(value);
}
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedMinutes) {
setMinutes(value);
}
};
const isValid = hours !== "" || minutes !== "";
return (
<Modal
visible={visible}
onClose={onClose}
title={t("update_playtime_title")}
description={t("update_playtime_description", {
game: game.title,
})}
>
<div className="change-game-playtime-modal__content">
{!game.hasManuallyUpdatedPlaytime && (
<div className="change-game-playtime-modal__warning">
<AlertIcon size={16} />
<span>{t("manual_playtime_warning")}</span>
</div>
)}
<div className="change-game-playtime-modal__inputs">
<TextField
label={t("hours")}
type="number"
min="0"
max={maxAllowedHours.toString()}
value={hours}
onChange={handleHoursChange}
placeholder="0"
theme="dark"
/>
<TextField
label={t("minutes")}
type="number"
min="0"
max={maxAllowedMinutes.toString()}
value={minutes}
onChange={handleMinutesChange}
placeholder="0"
theme="dark"
/>
</div>
<div className="change-game-playtime-modal__actions">
<Button
onClick={handleChangePlaytime}
theme="outline"
disabled={!isValid || isSubmitting}
>
{t("update_playtime")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -7,6 +7,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { ResetAchievementsModal } from "./reset-achievements-modal";
import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { debounce } from "lodash-es";
@@ -43,6 +44,7 @@ export function GameOptionsModal({
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
const [showResetAchievementsModal, setShowResetAchievementsModal] =
useState(false);
const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
const [automaticCloudSync, setAutomaticCloudSync] = useState(
game.automaticCloudSync ?? false
@@ -228,6 +230,20 @@ export function GameOptionsModal({
}
};
const handleChangePlaytime = async (playtimeInSeconds: number) => {
try {
await window.electron.changeGamePlayTime(
game.shop,
game.objectId,
playtimeInSeconds
);
await updateGame();
showSuccessToast(t("update_playtime_success"));
} catch (error) {
showErrorToast(t("update_playtime_error"));
}
};
const handleToggleAutomaticCloudSync = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
@@ -264,6 +280,13 @@ export function GameOptionsModal({
game={game}
/>
<ChangeGamePlaytimeModal
visible={showChangePlaytimeModal}
onClose={() => setShowChangePlaytimeModal(false)}
changePlaytime={handleChangePlaytime}
game={game}
/>
<Modal
visible={visible}
title={game.title}
@@ -476,6 +499,13 @@ export function GameOptionsModal({
{t("reset_achievements")}
</Button>
<Button
onClick={() => setShowChangePlaytimeModal(true)}
theme="danger"
>
{t("update_game_playtime")}
</Button>
<Button
onClick={() => {
setShowDeleteModal(true);

View File

@@ -75,7 +75,9 @@
gap: 4px;
padding: 4px;
}
&__manual-playtime {
color: globals.$warning-color;
}
&__stats {
width: 100%;
display: flex;

View File

@@ -2,15 +2,16 @@ import { UserGame } from "@types";
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 { useCallback, useContext, useState } from "react";
import {
buildGameAchievementPath,
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
import "./user-library-game-card.scss";
@@ -31,6 +32,7 @@ export function UserLibraryGameCard({
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
@@ -83,77 +85,108 @@ export function UserLibraryGameCard({
);
return (
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={isTooltipHovered ? undefined : game.title}
>
<div className="user-library-game__overlay">
<small className="user-library-game__playtime">
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
<small
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} / {game.achievementCount}
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
<progress
max={1}
value={
game.unlockedAchievementCount / game.achievementCount
}
className="user-library-game__achievements-progress"
/>
</div>
)}
</div>
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div>
)}
</div>
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
/>
</button>
</li>
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
/>
</button>
</li>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
</>
);
}