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

This commit is contained in:
Chubby Granny Chaser
2025-09-28 01:01:51 +01:00
committed by GitHub
23 changed files with 852 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { Badge, UserProfile, UserStats } from "@types";
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -14,9 +14,12 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
libraryGames: UserGame[];
pinnedGames: UserGame[];
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -27,9 +30,12 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
libraryGames: [],
pinnedGames: [],
});
const { Provider } = userProfileContext;
@@ -49,6 +55,8 @@ export function UserProfileContextProvider({
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [libraryGames, setLibraryGames] = useState<UserGame[]>([]);
const [pinnedGames, setPinnedGames] = useState<UserGame[]>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
@@ -85,8 +93,34 @@ export function UserProfileContextProvider({
});
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
try {
const response = await window.electron.getUserLibrary(
userId,
12,
0,
sortBy
);
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
} else {
setLibraryGames([]);
setPinnedGames([]);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
}
},
[userId]
);
const getUserProfile = useCallback(async () => {
getUserStats();
getUserLibraryGames();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
@@ -102,7 +136,7 @@ export function UserProfileContextProvider({
navigate(-1);
}
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
@@ -111,6 +145,8 @@ export function UserProfileContextProvider({
useEffect(() => {
setUserProfile(null);
setLibraryGames([]);
setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
@@ -124,10 +160,13 @@ export function UserProfileContextProvider({
heroBackground,
isMe,
getUserProfile,
getUserLibraryGames,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
libraryGames,
pinnedGames,
}}
>
{children}

View File

@@ -37,6 +37,7 @@ import type {
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -126,6 +127,11 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
pinned: boolean
) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -287,6 +293,12 @@ declare global {
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (

View File

@@ -0,0 +1,27 @@
import { useState, useCallback } from "react";
interface SectionCollapseState {
pinned: boolean;
library: boolean;
}
export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false,
library: false,
});
const toggleSection = useCallback((section: keyof SectionCollapseState) => {
setCollapseState((prevState) => ({
...prevState,
[section]: !prevState[section],
}));
}, []);
return {
collapseState,
toggleSection,
isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library,
};
}

View File

@@ -3,11 +3,18 @@ import {
GearIcon,
HeartFillIcon,
HeartIcon,
PinIcon,
PinSlashIcon,
PlayIcon,
PlusCircleIcon,
} from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
useState(false);
const { isGameDeleting } = useDownload();
const { userDetails } = useUserDetails();
const {
game,
@@ -82,6 +90,29 @@ export function HeroPanelActions() {
}
};
const toggleGamePinned = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game?.isPinned && objectId) {
await window.electron.toggleGamePin(shop, objectId, false).then(() => {
showSuccessToast(t("game_removed_from_pinned"));
});
} else {
if (!objectId) return;
await window.electron.toggleGamePin(shop, objectId, true).then(() => {
showSuccessToast(t("game_added_to_pinned"));
});
}
updateLibrary();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const openGame = async () => {
if (game) {
if (game.executablePath) {
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
{userDetails && (
<Button
onClick={toggleGamePinned}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
>
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
</Button>
)}
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"

View File

@@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -89,7 +88,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="top"
data-tooltip-content={
@@ -97,11 +96,15 @@ export function HeroPanelPlaytime() {
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
data-tooltip-id={
game.hasManuallyUpdatedPlaytime
? "manual-playtime-warning"
: undefined
}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
@@ -119,7 +122,7 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
@@ -127,7 +130,6 @@ export function HeroPanelPlaytime() {
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>

View File

@@ -72,7 +72,6 @@ export function ChangeGamePlaytimeModal({
onSuccess?.(t("update_playtime_success"));
onClose();
} catch (error) {
console.log(error);
onError?.(t("update_playtime_error"));
} finally {
setIsSubmitting(false);

View File

@@ -0,0 +1,91 @@
export const sectionVariants = {
collapsed: {
opacity: 0,
y: -20,
height: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
opacity: { duration: 0.1 },
y: { duration: 0.1 },
height: { duration: 0.2 },
},
},
expanded: {
opacity: 1,
y: 0,
height: "auto",
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
opacity: { duration: 0.2, delay: 0.1 },
y: { duration: 0.3 },
height: { duration: 0.3 },
},
},
};
export const gameCardVariants = {
hidden: {
opacity: 0,
y: 20,
scale: 0.95,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
},
},
exit: {
opacity: 0,
y: -20,
scale: 0.95,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
export const gameGridVariants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: 0.3,
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
exit: {
opacity: 0,
transition: {
duration: 0.2,
},
},
};
export const chevronVariants = {
collapsed: {
rotate: 0,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
expanded: {
rotate: 90,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
};
export const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;

View File

@@ -54,8 +54,74 @@
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit);
}
&__section-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__section-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__collapse-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
flex-shrink: 0;
&:hover {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
}
&__tabs {
display: flex;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all ease 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
border-bottom-color: #c9aa71;
}
}
&__games-grid {

View File

@@ -3,23 +3,45 @@ 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 { TelescopeIcon } from "@primer/octicons-react";
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { UserGame } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserLibraryGameCard } from "./user-library-game-card";
import { SortOptions } from "./sort-options";
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
import { motion, AnimatePresence } from "framer-motion";
import {
sectionVariants,
gameCardVariants,
gameGridVariants,
chevronVariants,
GAME_STATS_ANIMATION_DURATION_IN_MS,
} from "./profile-animations";
import "./profile-content.scss";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
type SortOption = "playtime" | "achievementCount" | "playedRecently";
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
const {
userProfile,
isMe,
userStats,
libraryGames,
pinnedGames,
getUserLibraryGames,
} = useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const dispatch = useAppDispatch();
@@ -33,6 +55,12 @@ export function ProfileContent() {
}
}, [userProfile, dispatch]);
useEffect(() => {
if (userProfile) {
getUserLibraryGames(sortBy);
}
}, [sortBy, getUserLibraryGames, userProfile]);
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
@@ -64,6 +92,27 @@ export function ProfileContent() {
const { numberFormatter } = useFormat();
const gamesHaveChanged = (
current: UserGame[],
previous: UserGame[]
): boolean => {
if (current.length !== previous.length) return true;
return current.some(
(game, index) => game.objectId !== previous[index]?.objectId
);
};
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
useEffect(() => {
setPrevLibraryGames(libraryGames);
}, [libraryGames]);
useEffect(() => {
setPrevPinnedGames(pinnedGames);
}, [pinnedGames]);
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
@@ -79,14 +128,21 @@ export function ProfileContent() {
return <LockedProfile />;
}
const hasGames = userProfile?.libraryGames.length > 0;
const hasGames = libraryGames.length > 0;
const hasPinnedGames = pinnedGames.length > 0;
const hasAnyGames = hasGames || hasPinnedGames;
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
const shouldShowRightContent =
hasAnyGames || userProfile.friends.length > 0;
return (
<section className="profile-content__section">
<div className="profile-content__main">
{!hasGames && (
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
@@ -96,28 +152,167 @@ export function ProfileContent() {
</div>
)}
{hasGames && (
<>
<div className="profile-content__section-header">
<h2>{t("library")}</h2>
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<button
type="button"
className="profile-content__collapse-button"
onClick={() => toggleSection("pinned")}
aria-label={
isPinnedCollapsed
? "Expand pinned section"
: "Collapse pinned section"
}
>
<motion.div
variants={chevronVariants}
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!isPinnedCollapsed && (
<motion.div
key="pinned-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<motion.ul
className="profile-content__games-grid"
variants={
shouldAnimatePinned ? gameGridVariants : undefined
}
initial={shouldAnimatePinned ? "hidden" : undefined}
animate={shouldAnimatePinned ? "visible" : undefined}
exit={shouldAnimatePinned ? "exit" : undefined}
key={
shouldAnimatePinned
? `pinned-${sortBy}`
: `pinned-static`
}
>
{shouldAnimatePinned ? (
<AnimatePresence mode="wait">
{pinnedGames?.map((game, index) => (
<motion.li
key={game.objectId}
variants={gameCardVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</AnimatePresence>
) : (
pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))
)}
</motion.ul>
</motion.div>
)}
</AnimatePresence>
</div>
)}
<ul className="profile-content__games-grid">
{userProfile?.libraryGames?.map((game) => (
<UserLibraryGameCard
game={game}
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
</>
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
</div>
<motion.ul
className="profile-content__games-grid"
variants={
shouldAnimateLibrary ? gameGridVariants : undefined
}
initial={shouldAnimateLibrary ? "hidden" : undefined}
animate={shouldAnimateLibrary ? "visible" : undefined}
exit={shouldAnimateLibrary ? "exit" : undefined}
key={
shouldAnimateLibrary
? `library-${sortBy}`
: `library-static`
}
>
{shouldAnimateLibrary ? (
<AnimatePresence mode="wait">
{libraryGames?.map((game, index) => (
<motion.li
key={game.objectId}
variants={gameCardVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</AnimatePresence>
) : (
libraryGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))
)}
</motion.ul>
</div>
)}
</div>
)}
</div>
@@ -139,6 +334,13 @@ export function ProfileContent() {
numberFormatter,
t,
statsIndex,
libraryGames,
pinnedGames,
isPinnedCollapsed,
toggleSection,
shouldAnimateLibrary,
shouldAnimatePinned,
sortBy,
]);
return (

View File

@@ -0,0 +1,56 @@
@use "../../../scss/globals.scss";
.sort-options {
&__container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__label {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 400;
}
&__options {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 14px;
}
&__option {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
padding: 4px 0;
font-size: 14px;
font-weight: 300;
transition: all ease 0.2s;
display: flex;
align-items: center;
gap: 6px;
&:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.6);
}
&.active {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
span {
display: inline-block;
}
}
&__separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
}

View File

@@ -0,0 +1,45 @@
import { TrophyIcon, ClockIcon, HistoryIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./sort-options.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
interface SortOptionsProps {
sortBy: SortOption;
onSortChange: (sortBy: SortOption) => void;
}
export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) {
const { t } = useTranslation("user_profile");
return (
<div className="sort-options__container">
<span className="sort-options__label">Sort by:</span>
<div className="sort-options__options">
<button
className={`sort-options__option ${sortBy === "achievementCount" ? "active" : ""}`}
onClick={() => onSortChange("achievementCount")}
>
<TrophyIcon size={16} />
<span>{t("achievements_earned")}</span>
</button>
<span className="sort-options__separator">|</span>
<button
className={`sort-options__option ${sortBy === "playedRecently" ? "active" : ""}`}
onClick={() => onSortChange("playedRecently")}
>
<HistoryIcon size={16} />
<span>{t("played_recently")}</span>
</button>
<span className="sort-options__separator">|</span>
<button
className={`sort-options__option ${sortBy === "playtime" ? "active" : ""}`}
onClick={() => onSortChange("playtime")}
>
<ClockIcon size={16} />
<span>{t("playtime")}</span>
</button>
</div>
</div>
);
}

View File

@@ -65,6 +65,47 @@
padding: 8px;
}
&__actions-container {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 2;
}
&__favorite-icon {
color: white;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
&__pin-button {
color: white;
background-color: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.9);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__playtime {
background-color: globals.$background-color;
color: globals.$muted-color;

View File

@@ -1,6 +1,6 @@
import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat } from "@renderer/hooks";
import { useFormat, useToast } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { useCallback, useContext, useState } from "react";
import {
@@ -9,7 +9,14 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react";
import {
ClockIcon,
TrophyIcon,
AlertFillIcon,
HeartFillIcon,
PinIcon,
PinSlashIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
@@ -28,11 +35,14 @@ export function UserLibraryGameCard({
onMouseEnter,
onMouseLeave,
}: UserLibraryGameCardProps) {
const { userProfile } = useContext(userProfileContext);
const { userProfile, isMe, getUserLibraryGames } =
useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const { showSuccessToast } = useToast();
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [isPinning, setIsPinning] = useState(false);
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
@@ -84,6 +94,28 @@ export function UserLibraryGameCard({
[numberFormatter, t]
);
const toggleGamePinned = async () => {
setIsPinning(true);
try {
await window.electron.toggleGamePin(
game.shop,
game.objectId,
!game.isPinned
);
await getUserLibraryGames();
if (game.isPinned) {
showSuccessToast(t("game_removed_from_pinned"));
} else {
showSuccessToast(t("game_added_to_pinned"));
}
} finally {
setIsPinning(false);
}
};
return (
<>
<li
@@ -98,6 +130,32 @@ export function UserLibraryGameCard({
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{(game.isFavorite || isMe) && (
<div className="user-library-game__actions-container">
{game.isFavorite && (
<div className="user-library-game__favorite-icon">
<HeartFillIcon size={12} />
</div>
)}
{isMe && (
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
)}
</div>
)}
<small
className="user-library-game__playtime"
data-tooltip-place="top"