feat: animation and number format

This commit is contained in:
Zamitto 2024-12-26 18:43:06 -03:00
parent ec289fe4c7
commit 16eaf4261a
4 changed files with 119 additions and 95 deletions

View File

@ -2,14 +2,17 @@ import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
export const formatDownloadProgress = (progress?: number) => { export const formatDownloadProgress = (
progress?: number,
fractionDigits?: number
) => {
if (!progress) return "0%"; if (!progress) return "0%";
const progressPercentage = progress * 100; const progressPercentage = progress * 100;
if (Number(progressPercentage.toFixed(2)) % 1 === 0) if (Number(progressPercentage.toFixed(fractionDigits ?? 2)) % 1 === 0)
return `${Math.floor(progressPercentage)}%`; return `${Math.floor(progressPercentage)}%`;
return `${progressPercentage.toFixed(2)}%`; return `${progressPercentage.toFixed(fractionDigits ?? 2)}%`;
}; };
export const getSteamLanguage = (language: string) => { export const getSteamLanguage = (language: string) => {

View File

@ -228,3 +228,11 @@ export const link = style({
cursor: "pointer", cursor: "pointer",
}, },
}); });
export const gameCardStats = style({
width: "100%",
height: "100%",
transition: "transform 0.5s ease-in-out",
flexShrink: "0",
flexGrow: "0",
});

View File

@ -1,5 +1,5 @@
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useCallback, useContext, useEffect, useMemo } from "react"; 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";
@ -12,12 +12,16 @@ import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile"; import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box"; import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box"; import { RecentGamesBox } from "./recent-games-box";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
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";
const GAME_STAT_ANIMATION_DURATION_IN_MS = 3500;
export function ProfileContent() { export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext); const { userProfile, isMe, userStats } = useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const statsAnimation = useRef(-1);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -31,6 +35,35 @@ export function ProfileContent() {
} }
}, [userProfile, dispatch]); }, [userProfile, dispatch]);
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
const handleOnMouseLeaveGameCard = () => {
setIsAnimationRunning(true);
};
useEffect(() => {
let zero = performance.now();
if (!isAnimationRunning) return;
statsAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= GAME_STAT_ANIMATION_DURATION_IN_MS) {
statsAnimation.current = requestAnimationFrame(animateClosing);
} else {
setStatsIndex((index) => index + 1);
zero = performance.now();
statsAnimation.current = requestAnimationFrame(animateClosing);
}
}
);
return () => {
cancelAnimationFrame(statsAnimation.current);
};
}, [setStatsIndex, isAnimationRunning]);
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const navigate = useNavigate(); const navigate = useNavigate();
@ -39,22 +72,6 @@ export function ProfileContent() {
return userProfile?.relation?.status === "ACCEPTED"; return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]); }, [userProfile]);
const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const content = useMemo(() => { const content = useMemo(() => {
if (!userProfile) return null; if (!userProfile) return null;
@ -101,7 +118,13 @@ export function ProfileContent() {
<ul className={styles.gamesGrid}> <ul className={styles.gamesGrid}>
{userProfile?.libraryGames?.map((game) => ( {userProfile?.libraryGames?.map((game) => (
<UserLibraryGameCard game={game} key={game.objectId} /> <UserLibraryGameCard
game={game}
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))} ))}
</ul> </ul>
</> </>
@ -125,8 +148,8 @@ export function ProfileContent() {
userStats, userStats,
numberFormatter, numberFormatter,
t, t,
formatPlayTime,
navigate, navigate,
statsIndex,
]); ]);
return ( return (

View File

@ -3,7 +3,7 @@ import * as styles from "./profile-content.css";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat } from "@renderer/hooks"; import { useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCallback, useContext, useEffect, useState } from "react"; import { useCallback, useContext } from "react";
import { import {
buildGameAchievementPath, buildGameAchievementPath,
buildGameDetailsPath, buildGameDetailsPath,
@ -18,43 +18,27 @@ import { steamUrlBuilder } from "@shared";
interface UserLibraryGameCardProps { interface UserLibraryGameCardProps {
game: UserGame; game: UserGame;
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
} }
export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) { export function UserLibraryGameCard({
game,
statIndex,
onMouseEnter,
onMouseLeave,
}: UserLibraryGameCardProps) {
const { userProfile } = useContext(userProfileContext); const { userProfile } = useContext(userProfileContext);
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const navigate = useNavigate(); const navigate = useNavigate();
const [mediaIndex, setMediaIndex] = useState(0); const getStatsItemCount = useCallback(() => {
let statsCount = 1;
const statsItemCount = if (game.achievementsPointsEarnedSum > 0) statsCount++;
Number(Boolean(game.achievementsPointsEarnedSum)) + return statsCount;
Number(Boolean(game.unlockedAchievementCount)); }, [game]);
console.log(game.title, statsItemCount);
useEffect(() => {
if (statsItemCount <= 1) return;
let zero = performance.now();
const animation = requestAnimationFrame(function animateClosing(time) {
if (time - zero <= 4000) {
requestAnimationFrame(animateClosing);
} else {
setMediaIndex((index) => {
if (index === statsItemCount - 1) return 0;
return index + 1;
});
zero = performance.now();
requestAnimationFrame(animateClosing);
}
});
return () => {
cancelAnimationFrame(animation);
};
}, [setMediaIndex, statsItemCount]);
const buildUserGameDetailsPath = useCallback( const buildUserGameDetailsPath = useCallback(
(game: UserGame) => { (game: UserGame) => {
@ -76,6 +60,14 @@ export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) {
[userProfile] [userProfile]
); );
const formatAchievementPoints = (number: number) => {
if (number < 100_000) return numberFormatter.format(number);
if (number < 1_000_000) return `${(number / 1000).toFixed(1)}K`;
return `${(number / 1_000_000).toFixed(1)}M`;
};
const formatPlayTime = useCallback( const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => { (playTimeInSeconds = 0) => {
const minutes = playTimeInSeconds / 60; const minutes = playTimeInSeconds / 60;
@ -94,7 +86,8 @@ export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) {
return ( return (
<li <li
key={game.objectId} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ style={{
borderRadius: 4, borderRadius: 4,
overflow: "hidden", overflow: "hidden",
@ -122,7 +115,7 @@ export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) {
height: "100%", height: "100%",
width: "100%", width: "100%",
background: background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.75) 25%, transparent 100%)", "linear-gradient(0deg, rgba(0, 0, 0, 0.70) 20%, transparent 100%)",
padding: 8, padding: 8,
}} }}
> >
@ -147,33 +140,32 @@ export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) {
style={{ style={{
width: "100%", width: "100%",
display: "flex", display: "flex",
overflow: "hidden", flexDirection: "column",
}} }}
> >
<div <div
style={{ style={{
width: "100%",
display: "flex", display: "flex",
flexDirection: "column", justifyContent: "space-between",
transition: "translate 0.5s ease-in-out", marginBottom: 8,
flexShrink: "0", color: vars.color.muted,
flexGrow: "0", overflow: "hidden",
translate: `${-100 * mediaIndex}%`, height: 18,
}} }}
> >
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", flexDirection: "column",
marginBottom: 8,
color: vars.color.muted,
}} }}
> >
<div <div
className={styles.gameCardStats}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}} }}
> >
<TrophyIcon size={13} /> <TrophyIcon size={13} />
@ -182,39 +174,37 @@ export function UserLibraryGameCard({ game }: UserLibraryGameCardProps) {
</span> </span>
</div> </div>
<span> {game.achievementsPointsEarnedSum > 0 && (
{formatDownloadProgress( <div
game.unlockedAchievementCount / game.achievementCount className={styles.gameCardStats}
)} style={{
</span> display: "flex",
gap: 5,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
alignItems: "center",
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div> </div>
<progress <span>
max={1} {formatDownloadProgress(
value={game.unlockedAchievementCount / game.achievementCount} game.unlockedAchievementCount / game.achievementCount,
className={styles.achievementsProgressBar} 1
/> )}
</span>
</div> </div>
{game.achievementsPointsEarnedSum > 0 && ( <progress
<div max={1}
style={{ value={game.unlockedAchievementCount / game.achievementCount}
display: "flex", className={styles.achievementsProgressBar}
justifyContent: "start", />
gap: 8,
width: "100%",
translate: `${-100 * mediaIndex}%`,
transition: "translate 0.5s ease-in-out",
alignItems: "center",
color: vars.color.muted,
flexShrink: "0",
flexGrow: "0",
}}
>
<HydraIcon width={16} height={16} />
{numberFormatter.format(game.achievementsPointsEarnedSum)}
</div>
)}
</div> </div>
)} )}
</div> </div>