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 { &__section-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2); 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 { &__tabs {

View File

@@ -3,7 +3,7 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ProfileHero } from "../profile-hero/profile-hero"; import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks"; import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } from "@primer/octicons-react"; import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LockedProfile } from "./locked-profile"; import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile"; import { ReportProfile } from "../report-profile/report-profile";
@@ -11,16 +11,80 @@ import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box"; import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box"; import { UserStatsBox } from "./user-stats-box";
import { UserLibraryGameCard } from "./user-library-game-card"; 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"; import "./profile-content.scss";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500; 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() { export function ProfileContent() {
const { userProfile, isMe, userStats, libraryGames, pinnedGames } = const { userProfile, isMe, userStats, libraryGames, pinnedGames } =
useContext(userProfileContext); useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0); const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const statsAnimation = useRef(-1); const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -101,53 +165,107 @@ export function ProfileContent() {
)} )}
{hasAnyGames && ( {hasAnyGames && (
<> <div>
{hasPinnedGames && ( {hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}> <div
style={{ marginBottom: "2rem" }}
>
<div className="profile-content__section-header"> <div className="profile-content__section-header">
<h2>{t("pinned")}</h2> <div className="profile-content__section-title-group">
<span>{pinnedGames.length}</span> <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> </div>
<ul className="profile-content__games-grid"> <AnimatePresence initial={true} mode="wait">
{pinnedGames?.map((game) => ( {!isPinnedCollapsed && (
<UserLibraryGameCard <motion.div
game={game} key="pinned-content"
key={game.objectId} variants={sectionVariants}
statIndex={statsIndex} initial="collapsed"
onMouseEnter={handleOnMouseEnterGameCard} animate="expanded"
onMouseLeave={handleOnMouseLeaveGameCard} exit="collapsed"
/> layout
))} >
</ul> <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> </div>
)} )}
{hasGames && ( {hasGames && (
<div> <div>
<div className="profile-content__section-header"> <div className="profile-content__section-header">
<h2>{t("library")}</h2> <div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
</div>
{userStats && ( {userStats && (
<span> <span
className="profile-content__section-count"
>
{numberFormatter.format(userStats.libraryCount)} {numberFormatter.format(userStats.libraryCount)}
</span> </span>
)} )}
</div> </div>
<ul className="profile-content__games-grid"> <ul className="profile-content__games-grid">
{libraryGames?.map((game) => ( {libraryGames?.map((game, index) => (
<UserLibraryGameCard <motion.li
game={game}
key={game.objectId} key={game.objectId}
statIndex={statsIndex} variants={gameCardVariants}
onMouseEnter={handleOnMouseEnterGameCard} initial="hidden"
onMouseLeave={handleOnMouseLeaveGameCard} animate="visible"
/> transition={{ delay: index * 0.1 }}
style={{ listStyle: 'none' }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
</> </div>
)} )}
</div> </div>
@@ -171,6 +289,8 @@ export function ProfileContent() {
statsIndex, statsIndex,
libraryGames, libraryGames,
pinnedGames, pinnedGames,
isPinnedCollapsed,
toggleSection,
]); ]);
return ( return (

View File

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