diff --git a/src/renderer/src/pages/achievement/achievements-content.tsx b/src/renderer/src/pages/achievement/achievements-content.tsx new file mode 100644 index 00000000..3520ffcc --- /dev/null +++ b/src/renderer/src/pages/achievement/achievements-content.tsx @@ -0,0 +1,207 @@ +import { setHeaderTitle } from "@renderer/features"; +import { useAppDispatch, useDate } from "@renderer/hooks"; +import { steamUrlBuilder } from "@shared"; +import { useContext, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as styles from "./achievements.css"; +import { formatDownloadProgress } from "@renderer/helpers"; +import { TrophyIcon } from "@primer/octicons-react"; +import { vars } from "@renderer/theme.css"; +import { gameDetailsContext } from "@renderer/context"; +import { GameShop, UserAchievement } from "@types"; +import { average } from "color.js"; +import Color from "color"; + +const HERO_ANIMATION_THRESHOLD = 25; + +interface AchievementsContentProps { + userId: string | null; + displayName: string | null; +} + +export function AchievementsContent({ + userId, + displayName, +}: AchievementsContentProps) { + const heroRef = useRef(null); + const containerRef = useRef(null); + const [isHeaderStuck, setIsHeaderStuck] = useState(false); + const [backdropOpactiy, setBackdropOpacity] = useState(1); + const [pageAchievements, setPageAchievements] = useState( + [] + ); + + const { t } = useTranslation("achievement"); + + const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } = + useContext(gameDetailsContext); + + const { formatDateTime } = useDate(); + + const dispatch = useAppDispatch(); + + useEffect(() => { + if (gameTitle) { + dispatch(setHeaderTitle(gameTitle)); + } + }, [dispatch, gameTitle]); + + const handleHeroLoad = async () => { + const output = await average(steamUrlBuilder.libraryHero(objectId!), { + amount: 1, + format: "hex", + }); + + const backgroundColor = output + ? (new Color(output).darken(0.7).toString() as string) + : ""; + + setGameColor(backgroundColor); + }; + + useEffect(() => { + if (objectId && shop && userId) { + window.electron + .getGameAchievements(objectId, shop as GameShop, userId) + .then((achievements) => { + setPageAchievements(achievements); + }); + } + }, [objectId, shop, userId]); + + const onScroll: React.UIEventHandler = (event) => { + const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; + + const scrollY = (event.target as HTMLDivElement).scrollTop; + const opacity = Math.max( + 0, + 1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD) + ); + + if (scrollY >= heroHeight && !isHeaderStuck) { + setIsHeaderStuck(true); + } + + if (scrollY <= heroHeight && isHeaderStuck) { + setIsHeaderStuck(false); + } + + setBackdropOpacity(opacity); + }; + + if (!objectId || !shop || !gameTitle) return null; + + const userAchievements = userId ? pageAchievements : achievements; + + const unlockedAchievementCount = userAchievements.filter( + (achievement) => achievement.unlocked + ).length; + + const totalAchievementCount = userAchievements.length; + + return ( +
+ {gameTitle} + +
+
+
+ +
+
+ {gameTitle} +
+
+
+ +
+

+ {displayName + ? t("user_achievements", { + displayName, + }) + : t("your_achievements")} +

+
+
+ + + {unlockedAchievementCount} / {totalAchievementCount} + +
+ + + {formatDownloadProgress( + unlockedAchievementCount / totalAchievementCount + )} + +
+ +
+ +
    + {userAchievements.map((achievement, index) => ( +
  • + {achievement.displayName} +
    +

    {achievement.displayName}

    +

    {achievement.description}

    + + {achievement.unlockTime && + formatDateTime(achievement.unlockTime)} + +
    +
  • + ))} +
+
+
+ ); +} diff --git a/src/renderer/src/pages/achievement/achievements-skeleton.tsx b/src/renderer/src/pages/achievement/achievements-skeleton.tsx new file mode 100644 index 00000000..f9ae81ac --- /dev/null +++ b/src/renderer/src/pages/achievement/achievements-skeleton.tsx @@ -0,0 +1,13 @@ +import Skeleton from "react-loading-skeleton"; +import * as styles from "./achievements.css"; + +export function AchievementsSkeleton() { + return ( +
+
+ +
+
+
+ ); +} diff --git a/src/renderer/src/pages/achievement/achievements.css.ts b/src/renderer/src/pages/achievement/achievements.css.ts index 792548ae..9dc6ac00 100644 --- a/src/renderer/src/pages/achievement/achievements.css.ts +++ b/src/renderer/src/pages/achievement/achievements.css.ts @@ -29,7 +29,7 @@ export const header = style({ }, }); -export const headerImage = style({ +export const hero = style({ position: "absolute", inset: "0", borderRadius: "4px", @@ -39,9 +39,17 @@ export const headerImage = style({ transition: "all ease 0.2s", }); -export const gameLogo = style({ +export const heroContent = style({ padding: `${SPACING_UNIT * 2}px`, - width: "300px", + height: "100%", + width: "100%", + display: "flex", + justifyContent: "space-between", + alignItems: "flex-end", +}); + +export const gameLogo = style({ + width: 300, }); export const container = style({ @@ -132,3 +140,40 @@ export const achievementsProgressBar = style({ backgroundColor: vars.color.muted, }, }); + +export const heroLogoBackdrop = style({ + width: "100%", + height: "100%", + background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)", + position: "absolute", + display: "flex", + flexDirection: "column", + justifyContent: "flex-end", +}); + +export const heroImageSkeleton = style({ + height: "300px", + "@media": { + "(min-width: 1250px)": { + height: "350px", + }, + }, +}); + +export const heroPanelSkeleton = style({ + width: "100%", + padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, + display: "flex", + alignItems: "center", + backgroundColor: vars.color.background, + height: "72px", + borderBottom: `solid 1px ${vars.color.border}`, +}); + +export const listItemSkeleton = style({ + width: "100%", + overflow: "hidden", + borderRadius: "4px", + padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`, + gap: `${SPACING_UNIT * 2}px`, +}); diff --git a/src/renderer/src/pages/achievement/achievements.tsx b/src/renderer/src/pages/achievement/achievements.tsx index fdffddd8..e5f5de31 100644 --- a/src/renderer/src/pages/achievement/achievements.tsx +++ b/src/renderer/src/pages/achievement/achievements.tsx @@ -1,16 +1,16 @@ import { setHeaderTitle } from "@renderer/features"; -import { useAppDispatch, useDate } from "@renderer/hooks"; -import { steamUrlBuilder } from "@shared"; -import type { GameShop, UserAchievement } from "@types"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useAppDispatch } from "@renderer/hooks"; +import type { GameShop } from "@types"; +import { useEffect } from "react"; import { useSearchParams } from "react-router-dom"; -import * as styles from "./achievements.css"; -import { formatDownloadProgress } from "@renderer/helpers"; -import { TrophyIcon } from "@primer/octicons-react"; import { vars } from "@renderer/theme.css"; - -const HERO_ANIMATION_THRESHOLD = 25; +import { + GameDetailsContextConsumer, + GameDetailsContextProvider, +} from "@renderer/context"; +import { SkeletonTheme } from "react-loading-skeleton"; +import { AchievementsSkeleton } from "./achievements-skeleton"; +import { AchievementsContent } from "./achievements-content"; export function Achievement() { const [searchParams] = useSearchParams(); @@ -20,157 +20,41 @@ export function Achievement() { const userId = searchParams.get("userId"); const displayName = searchParams.get("displayName"); - const heroRef = useRef(null); - const containerRef = useRef(null); - const [isHeaderStuck, setIsHeaderStuck] = useState(false); - const [backdropOpactiy, setBackdropOpacity] = useState(1); - - const { t } = useTranslation("achievement"); - - const { formatDateTime } = useDate(); - const dispatch = useAppDispatch(); - const [achievements, setAchievements] = useState([]); - - useEffect(() => { - if (objectId && shop) { - window.electron - .getGameAchievements(objectId, shop as GameShop, userId || undefined) - .then((achievements) => { - setAchievements(achievements); - }); - } - }, [objectId, shop, userId]); - useEffect(() => { if (title) { dispatch(setHeaderTitle(title)); } }, [dispatch, title]); - const onScroll: React.UIEventHandler = (event) => { - const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; - - const scrollY = (event.target as HTMLDivElement).scrollTop; - const opacity = Math.max( - 0, - 1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD) - ); - - if (scrollY >= heroHeight && !isHeaderStuck) { - setIsHeaderStuck(true); - } - - if (scrollY <= heroHeight && isHeaderStuck) { - setIsHeaderStuck(false); - } - - setBackdropOpacity(opacity); - }; - if (!objectId || !shop || !title) return null; - const unlockedAchievementCount = achievements.filter( - (achievement) => achievement.unlocked - ).length; - - const totalAchievementCount = achievements.length; - return ( -
- {title} - -
-
-
- - {title} -
- -
-

- {displayName - ? t("user_achievements", { - displayName, - }) - : t("your_achievements")} -

-
-
+ + {({ isLoading }) => { + return ( + - - - {unlockedAchievementCount} / {totalAchievementCount} - -
- - - {formatDownloadProgress( - unlockedAchievementCount / totalAchievementCount + {isLoading ? ( + + ) : ( + )} - -
- -
- -
    - {achievements.map((achievement, index) => ( -
  • - {achievement.displayName} -
    -

    {achievement.displayName}

    -

    {achievement.description}

    - - {achievement.unlockTime && - formatDateTime(achievement.unlockTime)} - -
    -
  • - ))} -
-
-
+ + ); + }} + + ); }