feat: redoing page

This commit is contained in:
Zamitto 2024-10-18 09:52:43 -03:00
parent 2700f27d03
commit ab27fd21d7
3 changed files with 170 additions and 205 deletions

View file

@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { import {
setProfileBackground, setProfileBackground,
@ -129,7 +129,16 @@ export function useUserDetails() {
const unblockUser = (userId: string) => window.electron.unblockUser(userId); const unblockUser = (userId: string) => window.electron.unblockUser(userId);
const hasActiveSubscription = userDetails?.subscription?.status === "active"; const hasActiveSubscription = useMemo(() => {
if (!userDetails?.subscription) {
return false;
}
return (
userDetails.subscription.expiresAt == null ||
new Date(userDetails.subscription.expiresAt) > new Date()
);
}, [userDetails]);
return { return {
userDetails, userDetails,

View file

@ -5,15 +5,19 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css"; import * as styles from "./achievements.css";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import {
CheckCircleIcon,
LockIcon,
PersonIcon,
TrophyIcon,
UnlockIcon,
} from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { UserAchievement } from "@types"; import { UserAchievement } from "@types";
import { average } from "color.js"; import { average } from "color.js";
import Color from "color"; import Color from "color";
const HERO_ANIMATION_THRESHOLD = 25;
interface UserInfo { interface UserInfo {
userId: string; userId: string;
displayName: string; displayName: string;
@ -32,180 +36,85 @@ interface AchievementListProps {
interface AchievementPanelProps { interface AchievementPanelProps {
user: UserInfo; user: UserInfo;
otherUser: UserInfo | null;
} }
function AchievementPanel({ user, otherUser }: AchievementPanelProps) { function AchievementPanel({ user }: AchievementPanelProps) {
const { t } = useTranslation("achievement"); const { userDetails, hasActiveSubscription } = useUserDetails();
const { userDetails } = useUserDetails();
const userTotalAchievementCount = user.achievements.length; const userTotalAchievementCount = user.achievements.length;
const userUnlockedAchievementCount = user.achievements.filter( const userUnlockedAchievementCount = user.achievements.filter(
(achievement) => achievement.unlocked (achievement) => achievement.unlocked
).length; ).length;
if (!otherUser) { const getProfileImage = (user: UserInfo) => {
return ( return (
<div <div className={styles.profileAvatar}>
style={{ {user.profileImageUrl ? (
display: "flex", <img
flexDirection: "row", className={styles.profileAvatar}
width: "100%", src={user.profileImageUrl}
gap: `${SPACING_UNIT * 2}px`, alt={user.displayName}
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
{t("your_achievements")}
</h1>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
width: "100%",
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{userUnlockedAchievementCount} / {userTotalAchievementCount}
</span>
</div>
<span>
{formatDownloadProgress(
userUnlockedAchievementCount / userTotalAchievementCount
)}
</span>
</div>
<progress
max={1}
value={userUnlockedAchievementCount / userTotalAchievementCount}
className={styles.achievementsProgressBar}
/> />
</div> ) : (
<PersonIcon size={24} />
)}
</div> </div>
); );
} };
const otherUserUnlockedAchievementCount = otherUser.achievements.filter( if (userDetails?.id == user.userId && !hasActiveSubscription) {
(achievement) => achievement.unlocked return <></>;
).length; }
const otherUserTotalAchievementCount = otherUser.achievements.length;
return ( return (
<div <div
style={{ style={{
display: "grid", display: "flex",
gridTemplateColumns: "2fr 1fr 1fr",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px`, alignItems: "center",
}} }}
> >
<div style={{ display: "flex", flexDirection: "column" }}> {getProfileImage(user)}
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}} }}
> >
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
{otherUser.displayName}
</h1>
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", alignItems: "center",
marginBottom: 8, gap: 8,
color: vars.color.muted,
}} }}
> >
<div <TrophyIcon size={13} />
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{otherUserUnlockedAchievementCount} /{" "}
{otherUserTotalAchievementCount}
</span>
</div>
<span> <span>
{formatDownloadProgress( {userUnlockedAchievementCount} / {userTotalAchievementCount}
otherUserUnlockedAchievementCount /
otherUserTotalAchievementCount
)}
</span> </span>
</div> </div>
<progress
max={1}
value={
otherUserUnlockedAchievementCount / otherUserTotalAchievementCount
}
className={styles.achievementsProgressBar}
/>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
{userDetails?.displayName}
</h1>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
width: "100%",
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{userUnlockedAchievementCount} / {userTotalAchievementCount}
</span>
</div>
<span> <span>
{formatDownloadProgress( {formatDownloadProgress(
userUnlockedAchievementCount / userTotalAchievementCount userUnlockedAchievementCount / userTotalAchievementCount
)} )}
</span> </span>
</div>
<progress
max={1}
value={userUnlockedAchievementCount / userTotalAchievementCount}
className={styles.achievementsProgressBar}
/>
</div> </div>
<progress
max={1}
value={userUnlockedAchievementCount / userTotalAchievementCount}
className={styles.achievementsProgressBar}
/>
</div> </div>
</div> </div>
); );
@ -220,18 +129,6 @@ function AchievementList({ user, otherUser }: AchievementListProps) {
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const getProfileImage = (imageUrl: string | null | undefined) => {
return (
<div className={styles.profileAvatar}>
{imageUrl ? (
<img className={styles.profileAvatar} src={imageUrl} alt={"teste"} />
) : (
<PersonIcon size={24} />
)}
</div>
);
};
if (!otherUserAchievements || otherUserAchievements.length === 0) { if (!otherUserAchievements || otherUserAchievements.length === 0) {
return ( return (
<ul className={styles.list}> <ul className={styles.list}>
@ -271,7 +168,7 @@ function AchievementList({ user, otherUser }: AchievementListProps) {
<li <li
key={index} key={index}
className={styles.listItem} className={styles.listItem}
style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr" }} style={{ display: "grid", gridTemplateColumns: "3fr 1fr 1fr" }}
> >
<div <div
style={{ style={{
@ -295,32 +192,46 @@ function AchievementList({ user, otherUser }: AchievementListProps) {
</div> </div>
</div> </div>
<div> <div
title={
otherUserAchievement.unlockTime
? formatDateTime(otherUserAchievement.unlockTime)
: undefined
}
>
{otherUserAchievement.unlockTime ? ( {otherUserAchievement.unlockTime ? (
<div style={{ whiteSpace: "nowrap" }}> <div
{getProfileImage(otherUser.profileImageUrl)} style={{
<small>{t("unlocked_at")}</small> whiteSpace: "nowrap",
<p>{formatDateTime(otherUserAchievement.unlockTime)}</p> display: "flex",
flexDirection: "column",
}}
>
<CheckCircleIcon />
<small>{formatDateTime(otherUserAchievement.unlockTime)}</small>
</div> </div>
) : ( ) : (
<div> <div>
<LockIcon /> <LockIcon />
<p>Não desbloqueada</p>
</div> </div>
)} )}
</div> </div>
<div> <div
title={
userDetails?.subscription && achievements[index].unlockTime
? formatDateTime(achievements[index].unlockTime)
: undefined
}
>
{userDetails?.subscription && achievements[index].unlockTime ? ( {userDetails?.subscription && achievements[index].unlockTime ? (
<div style={{ whiteSpace: "nowrap" }}> <div style={{ whiteSpace: "nowrap" }}>
{getProfileImage(user.profileImageUrl)} <UnlockIcon />
<small>{t("unlocked_at")}</small>
<p>{formatDateTime(achievements[index].unlockTime)}</p> <p>{formatDateTime(achievements[index].unlockTime)}</p>
</div> </div>
) : ( ) : (
<div> <div>
<LockIcon /> <LockIcon />
<p>Não desbloqueada</p>
</div> </div>
)} )}
</div> </div>
@ -334,7 +245,6 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
const heroRef = useRef<HTMLDivElement | null>(null); const heroRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false); const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } = const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
useContext(gameDetailsContext); useContext(gameDetailsContext);
@ -380,11 +290,6 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
const scrollY = (event.target as HTMLDivElement).scrollTop; const scrollY = (event.target as HTMLDivElement).scrollTop;
const opacity = Math.max(
0,
1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD)
);
if (scrollY >= heroHeight && !isHeaderStuck) { if (scrollY >= heroHeight && !isHeaderStuck) {
setIsHeaderStuck(true); setIsHeaderStuck(true);
} }
@ -392,8 +297,22 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
if (scrollY <= heroHeight && isHeaderStuck) { if (scrollY <= heroHeight && isHeaderStuck) {
setIsHeaderStuck(false); setIsHeaderStuck(false);
} }
};
setBackdropOpacity(opacity); const getProfileImage = (user: UserInfo) => {
return (
<div className={styles.profileAvatarSmall}>
{user.profileImageUrl ? (
<img
className={styles.profileAvatarSmall}
src={user.profileImageUrl}
alt={user.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
);
}; };
if (!objectId || !shop || !gameTitle || !userDetails) return null; if (!objectId || !shop || !gameTitle || !userDetails) return null;
@ -402,6 +321,7 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
<div className={styles.wrapper}> <div className={styles.wrapper}>
<img <img
src={steamUrlBuilder.libraryHero(objectId)} src={steamUrlBuilder.libraryHero(objectId)}
style={{ display: "none" }}
alt={gameTitle} alt={gameTitle}
className={styles.heroImage} className={styles.heroImage}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
@ -412,19 +332,14 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
onScroll={onScroll} onScroll={onScroll}
className={styles.container} className={styles.container}
> >
<div ref={heroRef} className={styles.hero}> <div
<div style={{
style={{ display: "flex",
backgroundColor: gameColor, flexDirection: "column",
flex: 1, background: `linear-gradient(0deg, ${vars.color.darkBackground} 0%, ${gameColor} 100%)`,
opacity: Math.min(1, 1 - backdropOpactiy), }}
}} >
/> <div ref={heroRef} className={styles.hero}>
<div
className={styles.heroLogoBackdrop}
style={{ opacity: backdropOpactiy }}
>
<div className={styles.heroContent}> <div className={styles.heroContent}>
<img <img
src={steamUrlBuilder.logo(objectId)} src={steamUrlBuilder.logo(objectId)}
@ -433,18 +348,50 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
/> />
</div> </div>
</div> </div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px`,
}}
>
<AchievementPanel
user={{
...userDetails,
userId: userDetails.id,
achievements: sortedAchievements,
}}
/>
{otherUser && <AchievementPanel user={otherUser} />}
</div>
</div> </div>
<div className={styles.panel({ stuck: isHeaderStuck })}> {otherUser && (
<AchievementPanel <div className={styles.panel({ stuck: isHeaderStuck })}>
user={{ <div
...userDetails, style={{
userId: userDetails.id, display: "grid",
achievements: sortedAchievements, gridTemplateColumns: "3fr 1fr 1fr",
}} padding: `${SPACING_UNIT}px`,
otherUser={otherUser} }}
/> >
</div> <div></div>
<div>{getProfileImage(otherUser)}</div>
<div>
{getProfileImage({
...userDetails,
userId: userDetails.id,
achievements: sortedAchievements,
})}
</div>
</div>
</div>
)}
<div <div
style={{ style={{
display: "flex", display: "flex",

View file

@ -2,7 +2,8 @@ import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes"; import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 300; export const HERO_HEIGHT = 150;
export const LOGO_HEIGHT = 100;
export const wrapper = style({ export const wrapper = style({
display: "flex", display: "flex",
@ -38,6 +39,7 @@ export const heroImage = style({
transition: "all ease 0.2s", transition: "all ease 0.2s",
position: "absolute", position: "absolute",
zIndex: "0", zIndex: "0",
filter: "blur(5px)",
"@media": { "@media": {
"(min-width: 1250px)": { "(min-width: 1250px)": {
objectPosition: "center", objectPosition: "center",
@ -53,11 +55,11 @@ export const heroContent = style({
width: "100%", width: "100%",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "flex-end", alignItems: "center",
}); });
export const gameLogo = style({ export const gameLogo = style({
width: 300, height: LOGO_HEIGHT,
}); });
export const container = style({ export const container = style({
@ -72,14 +74,10 @@ export const container = style({
export const panel = recipe({ export const panel = recipe({
base: { base: {
width: "100%", width: "100%",
height: "150px",
minHeight: "150px",
padding: `${SPACING_UNIT * 2}px`,
backgroundColor: vars.color.darkBackground, backgroundColor: vars.color.darkBackground,
transition: "all ease 0.2s", transition: "all ease 0.2s",
borderBottom: `solid 1px ${vars.color.border}`, borderBottom: `solid 1px ${vars.color.border}`,
position: "sticky", position: "sticky",
overflow: "hidden",
top: "0", top: "0",
zIndex: "1", zIndex: "1",
}, },
@ -149,7 +147,6 @@ export const achievementsProgressBar = style({
export const heroLogoBackdrop = style({ export const heroLogoBackdrop = style({
width: "100%", width: "100%",
height: "100%", height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)",
position: "absolute", position: "absolute",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -184,6 +181,18 @@ export const listItemSkeleton = style({
}); });
export const profileAvatar = style({ export const profileAvatar = style({
height: "54px",
width: "54px",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
objectFit: "cover",
});
export const profileAvatarSmall = style({
height: "32px", height: "32px",
width: "32px", width: "32px",
borderRadius: "4px", borderRadius: "4px",