Merge pull request #1789 from hydralauncher/feat/pinning-games
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled

fix: single pinned game not visible in profile
This commit is contained in:
Chubby Granny Chaser
2025-09-27 16:43:36 +01:00
committed by GitHub
6 changed files with 310 additions and 44 deletions

View File

@@ -14,6 +14,7 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: () => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
@@ -29,6 +30,7 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async () => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
@@ -149,6 +151,7 @@ export function UserProfileContextProvider({
heroBackground,
isMe,
getUserProfile,
getUserLibraryGames,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,

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

@@ -54,8 +54,38 @@
&__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-count {
margin-left: auto;
}
&__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 {

View File

@@ -3,7 +3,7 @@ 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 { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
@@ -11,16 +11,80 @@ 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 { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
import { motion, AnimatePresence } from "framer-motion";
import "./profile-content.scss";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
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 },
},
},
};
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],
},
},
};
const chevronVariants = {
collapsed: {
rotate: 0,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
expanded: {
rotate: 90,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
};
export function ProfileContent() {
const { userProfile, isMe, userStats, libraryGames, pinnedGames } =
useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const dispatch = useAppDispatch();
@@ -82,13 +146,15 @@ export function ProfileContent() {
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 && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
@@ -98,48 +164,106 @@ export function ProfileContent() {
</div>
)}
{hasGames && (
<>
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<h2>{t("pinned")}</h2>
<span>{pinnedGames.length}</span>
<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>
</div>
<span className="profile-content__section-count">
{pinnedGames.length}
</span>
</div>
<AnimatePresence initial={true} mode="wait">
{!isPinnedCollapsed && (
<motion.div
key="pinned-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game, index) => (
<motion.li
key={game.objectId}
variants={gameCardVariants}
initial="hidden"
animate="visible"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
</div>
{userStats && (
<span className="profile-content__section-count">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<UserLibraryGameCard
game={game}
{libraryGames?.map((game, index) => (
<motion.li
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
variants={gameCardVariants}
initial="hidden"
animate="visible"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</ul>
</div>
)}
<div className="profile-content__section-header">
<h2>{t("library")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<ul className="profile-content__games-grid">
{libraryGames?.map((game) => (
<UserLibraryGameCard
game={game}
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
</>
</div>
)}
</div>
@@ -163,6 +287,8 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
isPinnedCollapsed,
toggleSection,
]);
return (

View File

@@ -65,18 +65,45 @@
padding: 8px;
}
&__favorite-icon {
&__actions-container {
position: absolute;
top: 8px;
right: 8px;
color: #ff6b6b;
display: flex;
gap: 6px;
z-index: 2;
}
&__favorite-icon {
color: white;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
padding: 4px;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
&__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 {

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 {
@@ -14,6 +14,8 @@ import {
TrophyIcon,
AlertFillIcon,
HeartFillIcon,
PinIcon,
PinSlashIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
@@ -33,11 +35,15 @@ export function UserLibraryGameCard({
onMouseEnter,
onMouseLeave,
}: UserLibraryGameCardProps) {
const { userProfile } = useContext(userProfileContext);
const { userProfile, isMe, getUserLibraryGames } =
useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { t: tGame } = useTranslation("game_details");
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;
@@ -89,6 +95,32 @@ export function UserLibraryGameCard({
[numberFormatter, t]
);
const toggleGamePinned = async () => {
setIsPinning(true);
try {
if (game.isPinned) {
await window.electron
.removeGameFromPinned(game.shop, game.objectId)
.then(() => {
showSuccessToast(tGame("game_removed_from_pinned"));
});
} else {
await window.electron
.addGameToPinned(game.shop, game.objectId)
.then(() => {
showSuccessToast(tGame("game_added_to_pinned"));
});
}
await new Promise((resolve) => setTimeout(resolve, 1000));
await getUserLibraryGames();
} finally {
setIsPinning(false);
}
};
return (
<>
<li
@@ -103,9 +135,30 @@ export function UserLibraryGameCard({
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{game.isFavorite && (
<div className="user-library-game__favorite-icon">
<HeartFillIcon size={14} />
{(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