diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 3b10b3e5..499b85ee 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,6 +14,7 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; + getUserLibraryGames: () => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; @@ -29,6 +30,7 @@ export const userProfileContext = createContext({ 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, diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts new file mode 100644 index 00000000..7cd22224 --- /dev/null +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -0,0 +1,27 @@ +import { useState, useCallback } from "react"; + +interface SectionCollapseState { + pinned: boolean; + library: boolean; +} + +export function useSectionCollapse() { + const [collapseState, setCollapseState] = useState({ + 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, + }; +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 57a24e2f..8f2fcf6f 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -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 { diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 7b1ac8e2..f1bb2ab8 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -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 (
- {!hasGames && ( + {!hasAnyGames && (
@@ -98,48 +164,106 @@ export function ProfileContent() {
)} - {hasGames && ( - <> + {hasAnyGames && ( +
{hasPinnedGames && (
-

{t("pinned")}

- {pinnedGames.length} +
+ +

{t("pinned")}

+
+ + {pinnedGames.length} + +
+ + + {!isPinnedCollapsed && ( + +
    + {pinnedGames?.map((game, index) => ( + + + + ))} +
+
+ )} +
+
+ )} + + {hasGames && ( +
+
+
+

{t("library")}

+
+ {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )}
    - {pinnedGames?.map((game) => ( - ( + + variants={gameCardVariants} + initial="hidden" + animate="visible" + transition={{ delay: index * 0.1 }} + style={{ listStyle: "none" }} + > + + ))}
)} - -
-

{t("library")}

- {userStats && ( - {numberFormatter.format(userStats.libraryCount)} - )} -
- -
    - {libraryGames?.map((game) => ( - - ))} -
- +
)}
@@ -163,6 +287,8 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, + isPinnedCollapsed, + toggleSection, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index ba3f5602..ab1f3456 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -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 { diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index e64cd3bb..e00b5863 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -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 ( <>
  • navigate(buildUserGameDetailsPath(game))} >
    - {game.isFavorite && ( -
    - + {(game.isFavorite || isMe) && ( +
    + {game.isFavorite && ( +
    + +
    + )} + {isMe && ( + + )}
    )}