feat: added functionality to collapse/expand pinned list in user profile

This commit is contained in:
Moyasee
2025-09-26 16:54:10 +03:00
parent fd1f13225b
commit f027f05e02
4 changed files with 229 additions and 38 deletions

View File

@@ -0,0 +1,30 @@
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,40 @@
&__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();
@@ -101,53 +165,107 @@ export function ProfileContent() {
)}
{hasAnyGames && (
<>
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<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>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<UserLibraryGameCard
game={game}
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
<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">
<h2>{t("library")}</h2>
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
</div>
{userStats && (
<span>
<span
className="profile-content__section-count"
>
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
<ul className="profile-content__games-grid">
{libraryGames?.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>
)}
</div>
@@ -171,6 +289,8 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
isPinnedCollapsed,
toggleSection,
]);
return (

View File

@@ -35,7 +35,8 @@ export function UserLibraryGameCard({
onMouseEnter,
onMouseLeave,
}: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } = useContext(userProfileContext);
const { userProfile, isMe, getUserLibraryGames } =
useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { t: tGame } = useTranslation("game_details");
const { numberFormatter } = useFormat();
@@ -99,17 +100,21 @@ export function UserLibraryGameCard({
try {
if (game.isPinned) {
await window.electron.removeGameFromPinned(game.shop, game.objectId).then(() => {
showSuccessToast(tGame("game_removed_from_pinned"));
});
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 window.electron
.addGameToPinned(game.shop, game.objectId)
.then(() => {
showSuccessToast(tGame("game_added_to_pinned"));
});
}
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
await getUserLibraryGames();
} finally {
setIsPinning(false);
@@ -147,7 +152,11 @@ export function UserLibraryGameCard({
}}
disabled={isPinning}
>
{game.isPinned ? <PinSlashIcon size={12} /> : <PinIcon size={12} />}
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
)}
</div>