feat: adding profile sorting

This commit is contained in:
Chubby Granny Chaser
2025-09-28 00:37:22 +01:00
parent a71293148d
commit e96cb3228e
17 changed files with 437 additions and 161 deletions

View File

@@ -14,7 +14,7 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
@@ -30,7 +30,7 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
@@ -93,21 +93,30 @@ export function UserProfileContextProvider({
});
}, [userId]);
const getUserLibraryGames = useCallback(async () => {
try {
const response = await window.electron.getUserLibrary(userId);
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
} else {
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([]);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
}
}, [userId]);
},
[userId]
);
const getUserProfile = useCallback(async () => {
getUserStats();

View File

@@ -127,8 +127,11 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
addGameToPinned: (shop: GameShop, objectId: string) => Promise<void>;
removeGameFromPinned: (shop: GameShop, objectId: string) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
pinned: boolean
) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -293,7 +296,8 @@ declare global {
getUserLibrary: (
userId: string,
take?: number,
skip?: number
skip?: number,
sortBy?: string
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;

View File

@@ -94,14 +94,14 @@ export function HeroPanelActions() {
setToggleLibraryGameDisabled(true);
try {
if (game?.pinned && objectId) {
await window.electron.removeGameFromPinned(shop, objectId).then(() => {
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.addGameToPinned(shop, objectId).then(() => {
await window.electron.toggleGamePin(shop, objectId, true).then(() => {
showSuccessToast(t("game_added_to_pinned"));
});
}
@@ -236,7 +236,7 @@ export function HeroPanelActions() {
disabled={deleting}
className="hero-panel-actions__action"
>
{game.pinned ? <PinSlashIcon /> : <PinIcon />}
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
</Button>
)}

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

@@ -65,8 +65,103 @@
flex: 1;
}
&__section-count {
margin-left: auto;
&__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;
}
&__sort-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__sort-label {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 400;
}
&__sort-options {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 14px;
}
&__sort-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;
}
&.loading {
color: rgba(201, 170, 113, 0.8);
font-weight: 500;
position: relative;
&::after {
content: "";
position: absolute;
right: -20px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
border: 2px solid rgba(201, 170, 113, 0.3);
border-top: 2px solid rgba(201, 170, 113, 0.8);
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
span {
display: inline-block;
}
}
@keyframes spin {
0% {
transform: translateY(-50%) rotate(0deg);
}
100% {
transform: translateY(-50%) rotate(360deg);
}
}
&__sort-separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
&__collapse-button {

View File

@@ -3,8 +3,15 @@ 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, ChevronRightIcon } from "@primer/octicons-react";
import {
TelescopeIcon,
ChevronRightIcon,
TrophyIcon,
ClockIcon,
HistoryIcon,
} 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";
@@ -59,6 +66,35 @@ const gameCardVariants = {
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],
},
},
};
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,
},
},
};
const chevronVariants = {
@@ -78,11 +114,23 @@ const chevronVariants = {
},
};
type SortOption = "playtime" | "achievementCount" | "playedRecently";
export function ProfileContent() {
const { userProfile, isMe, userStats, libraryGames, pinnedGames } =
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 [isLoadingSort, setIsLoadingSort] = useState(false);
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
@@ -98,6 +146,15 @@ export function ProfileContent() {
}
}, [userProfile, dispatch]);
useEffect(() => {
if (userProfile) {
setIsLoadingSort(true);
getUserLibraryGames(sortBy).finally(() => {
setIsLoadingSort(false);
});
}
}, [sortBy, getUserLibraryGames, userProfile]);
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
@@ -129,10 +186,68 @@ export function ProfileContent() {
const { numberFormatter } = useFormat();
// Function to check if game lists have changed
const gamesHaveChanged = (
current: UserGame[],
previous: UserGame[]
): boolean => {
if (current.length !== previous.length) return true;
return current.some(
(game, index) => game.objectId !== previous[index]?.objectId
);
};
// Check if animations should run
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
// Update previous games when lists change
useEffect(() => {
setPrevLibraryGames(libraryGames);
}, [libraryGames]);
useEffect(() => {
setPrevPinnedGames(pinnedGames);
}, [pinnedGames]);
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
const SortOptions = () => (
<div className="profile-content__sort-container">
<span className="profile-content__sort-label">Sort by:</span>
<div className="profile-content__sort-options">
<button
className={`profile-content__sort-option ${sortBy === "achievementCount" ? "active" : ""} ${isLoadingSort && sortBy === "achievementCount" ? "loading" : ""}`}
onClick={() => setSortBy("achievementCount")}
disabled={isLoadingSort}
>
<TrophyIcon size={16} />
<span>{t("achievements_earned")}</span>
</button>
<span className="profile-content__sort-separator">|</span>
<button
className={`profile-content__sort-option ${sortBy === "playedRecently" ? "active" : ""} ${isLoadingSort && sortBy === "playedRecently" ? "loading" : ""}`}
onClick={() => setSortBy("playedRecently")}
disabled={isLoadingSort}
>
<HistoryIcon size={16} />
<span>{t("played_recently")}</span>
</button>
<span className="profile-content__sort-separator">|</span>
<button
className={`profile-content__sort-option ${sortBy === "playtime" ? "active" : ""} ${isLoadingSort && sortBy === "playtime" ? "loading" : ""}`}
onClick={() => setSortBy("playtime")}
disabled={isLoadingSort}
>
<ClockIcon size={16} />
<span>{t("playtime")}</span>
</button>
</div>
</div>
);
const content = useMemo(() => {
if (!userProfile) return null;
@@ -154,6 +269,8 @@ export function ProfileContent() {
return (
<section className="profile-content__section">
<div className="profile-content__main">
{hasAnyGames && <SortOptions />}
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
@@ -188,10 +305,10 @@ export function ProfileContent() {
</motion.div>
</button>
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
<span className="profile-content__section-count">
{pinnedGames.length}
</span>
</div>
<AnimatePresence initial={true} mode="wait">
@@ -204,25 +321,57 @@ export function ProfileContent() {
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.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>
@@ -234,33 +383,62 @@ export function ProfileContent() {
<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>
{userStats && (
<span className="profile-content__section-count">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
<ul className="profile-content__games-grid">
{libraryGames?.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.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>

View File

@@ -38,7 +38,6 @@ export function UserLibraryGameCard({
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();
@@ -99,23 +98,19 @@ export function UserLibraryGameCard({
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 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);
}